Compare commits

...

30 Commits

Author SHA1 Message Date
b47935de86 Update README.md 2026-01-10 01:01:15 +08:00
LC mac
46f748301e chore: cleanup and prepare v1.1.0 release (Makefile clean up) 2026-01-10 00:52:58 +08:00
LC mac
27ee16f21a chore: cleanup and prepare v1.1.0 release 2026-01-10 00:50:50 +08:00
LC mac
0a748dd8b0 vendor: update wheels 2026-01-10 00:45:54 +08:00
LC mac
c41928688c chore: cleanup build artifacts and add .gitignore 2026-01-10 00:39:06 +08:00
d4f6e3d207 rebuild the offline wheel using Linux environment; add a test asc file to git 2026-01-09 14:41:45 +00:00
LC mac
a6c84f81ee chore: bump version to v1.1.0 2026-01-09 19:30:10 +08:00
109829f1f5 Update playbook.md 2026-01-09 19:20:22 +08:00
LC mac
2f7433b704 add wheels and update docs 2026-01-09 19:16:28 +08:00
LC mac
6457ec2cee chore: recompile requirements.txt 2026-01-09 18:55:57 +08:00
LC mac
0949fe9792 feat(bip85): add gen-child command (v1.0.6)
Implements BIP85 child mnemonic derivation with full interoperability.

Features:
- Derives child BIP39 mnemonics (12/15/18/21/24 words) from master mnemonic
- BIP85 path: m/83696968'/39'/0'/{words}'/{index}'
- Supports optional master BIP39 passphrase
- Reuses existing input modes (--interactive, --mnemonic-stdin)
- Follows existing UX patterns (--off-screen, --file, PGP encryption)
- Offline-first with NetworkGuard protection

Testing:
- Adds deterministic regression tests for BIP85 spec compliance
- Verified against official BIP85 test vectors
- CLI smoke tests for end-to-end validation

Interoperability:
- Produces mnemonics compatible with Coldcard, Ian Coleman tool, etc.
- Test vector verified: 'girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose'

Version bumped to v1.0.6
2026-01-09 18:46:19 +08:00
21b9389591 add vendor folder for linux-aarch64 2026-01-09 02:58:31 +08:00
LC mac
369c8595a1 Refs: offline install via vendored wheels and test verification workflow (see README/playbook updates) 2026-01-08 01:45:25 +08:00
LC mac
2807982209 vendor: add macOS ARM64 wheels for offline installation (Python 3.12) 2026-01-08 00:42:22 +08:00
LC mac
84953dbe5a add BestPractices.md 2026-01-08 00:12:37 +08:00
LC mac
6fd7cd4e79 modified playbook.md 2026-01-08 00:05:27 +08:00
LC mac
5a52eb0954 test: add offline integrity test suite with frozen vectors 2026-01-07 23:54:25 +08:00
LC mac
28f01a613a format the README.md 2026-01-07 23:14:05 +08:00
a9af9d33af add Makefile Dockerfile README.md 2026-01-07 21:03:00 +08:00
129b09fcd9 adding wheelhouse 2026-01-07 20:53:37 +08:00
LC mac
d02e1d872e with figerprint match option when encryption with asc file, extra assurance the file is the target asc file 2026-01-07 02:21:03 +08:00
LC mac
875fa17d6c patch the recover logic with interactive mode 2026-01-07 01:45:35 +08:00
LC mac
ccd070dc56 modified README 2026-01-07 00:53:39 +08:00
LC mac
ce26b3560a modified doc to v1.0.5 2026-01-07 00:40:47 +08:00
LC
4cf32f9ba0 Remove venv from previous commit 2026-01-06 15:48:15 +00:00
LC
94fcb993db Fix: ignore venv and commit project files 2026-01-06 15:25:11 +00:00
LC
5c343c7944 Fix secure-mode logic: move memory zeroing after PGP encryption to ensure mnemonic is included in payload 2026-01-05 16:48:32 +00:00
LC
6679e4ef9e Update README version to v1.0.4 2026-01-05 16:12:43 +00:00
LC
f02e04389c Update version to v1.0.4 2026-01-05 16:12:31 +00:00
LC
a4f188af36 v1.0.4: Fix bugs in recover input validation and secure mode file handling, clean up _toDelete folder 2026-01-05 16:12:06 +00:00
61 changed files with 4017 additions and 1666 deletions

81
.gitignore vendored
View File

@@ -1,24 +1,79 @@
.venv/
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyc
.vscode/
.env
.DS_Store
.idea/
logs/
*.log
coverage/
_toDelete/
dist/
build/
*.so
.Python
*.egg-info/
.ipynb_checkpoints/
.envrc
# Virtual environments
.venv/
.venv312/
.venv-*/
venv/
ENV/
env/
# Build artifacts
dist/
build/
*.spec
wheelhouse/
# PyInstaller temporary files
src/*_frozen.py
# Testing
.pytest_cache/
.cache/
coverage/
# Type checking
.mypy_cache/
.pyre/
.pytype/
.cache/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.envrc
# Logs
logs/
*.log
# Database
*.sqlite3
*.db
# Project specific
.wallet/
releases/*/
_toDelete/
.potentialfix.md
# PGP keys (except test data)
*.asc
!tests/data/recipient.asc
# Vendor cleanup (wrong architectures/test builds)
vendor/linux-aarch64/
vendor/macos-arm64-docker/
vendor/*-docker/
# Keep official vendor directories
# vendor/macos-arm64/
# vendor/linux-x86_64/
# vendor/PROVENANCE.md

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Dockerfile
# Build environment for hdwalletpy: Python 3.12 + build tools + venv support
# Used for: (1) Building Linux x86_64 wheels, (2) Development container
FROM python:3.12-slim
# Install build tools, headers, and venv module
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
python3-dev \
libffi-dev \
python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Set working directory for bind mounts
WORKDIR /app
# Default command (can be overridden)
CMD ["/bin/bash"]

334
Makefile Normal file
View File

@@ -0,0 +1,334 @@
# Makefile for hdwalletpy workflow
# - Build reusable Docker image (Python 3.12)
# - Vendor multi-platform wheels for offline air-gapped use
# - Compile wheels in container (wheelhouse)
# - Create host venv and install from wheelhouse or vendor/
# - Optionally run inside a warm container
# ---------- Platform Detection ----------
UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m)
ifeq ($(UNAME_S),Darwin)
PLATFORM := macos
ifeq ($(UNAME_M),arm64)
ARCH := arm64
else
$(error Unsupported macOS architecture: $(UNAME_M))
endif
else ifeq ($(UNAME_S),Linux)
PLATFORM := linux
ARCH := x86_64
else
$(error Unsupported OS: $(UNAME_S))
endif
VENDOR_DIR := vendor/$(PLATFORM)-$(ARCH)
# ---------- Config ----------
IMAGE := hdwallet-build:3.12
CONTAINER := hdwallet-dev
WORKDIR := /app
VENV_HOST := .venv
VENV_CONTAINER := /opt/venv
WHEELHOUSE := wheelhouse
# Vendor directories for air-gapped deployment
VENDOR_MACOS := vendor/macos-arm64
VENDOR_LINUX := vendor/linux-x86_64
# ---------- Help ----------
.PHONY: help
help:
@echo "pyhdwallet build system (macOS + Linux compatible)"
@echo ""
@echo "📦 Vendoring (for offline/air-gapped use):"
@echo " make vendor-macos - Build macOS ARM64 wheels (requires macOS native)"
@echo " make vendor-linux - Build Linux x86_64 wheels (Docker - any OS)"
@echo " make vendor-all - Build wheels for both platforms (macOS native + Docker)"
@echo " make verify-vendor - Test offline installation from vendor/"
@echo ""
@echo "🔨 Binary Distribution:"
@echo " make binary - Build standalone binary for current platform"
@echo " make binary-linux - Build Linux binary via Docker (any OS)"
@echo " make binary-all - Build binaries for all platforms"
@echo ""
@echo "🚀 Release Management:"
@echo " make release - Build complete release (binaries + vendors + checksums)"
@echo " make release-binaries - Copy binaries to releases/vX.Y.Z/"
@echo " make release-checksums - Generate SHA256SUMS for binaries"
@echo " make release-test - Test release binaries"
@echo " make clean-release - Remove all release artifacts"
@echo ""
@echo "🛠️ Development workflow (works on macOS + Linux):"
@echo " make build-image - Build Docker image (Python 3.12)"
@echo " make wheels - Build wheels into ./$(WHEELHOUSE)"
@echo " make install - Create venv and install dependencies"
@echo " make test - Run test suite"
@echo " make up - Start warm dev container"
@echo " make shell - Open shell in warm container"
@echo " make down - Stop and remove dev container"
@echo ""
@echo "🧹 Cleanup:"
@echo " make clean - Remove venv, wheelhouse, vendor/"
@echo " make clean-vendor - Remove vendor/ only"
@echo " make clean-release - Remove releases/ only"
@echo ""
@echo " Platform Info:"
@echo " make info - Show platform detection info"
@echo ""
@echo "Platform notes:"
@echo " • vendor-macos requires native macOS (uses .venv312 with ARM64 Python)"
@echo " • vendor-linux works on any platform with Docker"
@echo " • binary-linux works on any platform with Docker"
@echo " • All other targets work on macOS and Linux"
# ---------- Build reusable image ----------
.PHONY: build-image
build-image:
docker build -t $(IMAGE) .
# ---------- Vendoring for Air-Gapped Use ----------
.PHONY: vendor-macos
vendor-macos: requirements.txt
@echo "Building macOS ARM64 wheels (native)..."
@if [ ! -f ".venv312/bin/pip" ]; then \
echo "ERROR: .venv312 not found. Create it first:"; \
echo " python3.12 -m venv .venv312 && source .venv312/bin/activate"; \
exit 1; \
fi
mkdir -p $(VENDOR_MACOS)
.venv312/bin/pip download --dest $(VENDOR_MACOS) -r requirements.txt
cd $(VENDOR_MACOS) && shasum -a 256 *.whl > SHA256SUMS
@echo "✓ macOS ARM64 wheels: $(VENDOR_MACOS)/"
.PHONY: vendor-linux
vendor-linux: requirements.txt build-image
@echo "Building Linux x86_64 wheels (Docker)..."
mkdir -p $(VENDOR_LINUX)
docker run --rm \
-v "$$PWD":$(WORKDIR) \
-w $(WORKDIR) \
$(IMAGE) \
bash -c " \
pip install --upgrade pip && \
pip download --dest $(VENDOR_LINUX) -r requirements.txt && \
pip wheel --wheel-dir $(VENDOR_LINUX) --no-deps $(VENDOR_LINUX)/*.tar.gz 2>/dev/null || true && \
rm -f $(VENDOR_LINUX)/*.tar.gz && \
cd $(VENDOR_LINUX) && sha256sum *.whl > SHA256SUMS \
"
@echo "✓ Linux x86_64 wheels: $(VENDOR_LINUX)/"
.PHONY: vendor-all
vendor-all: vendor-macos vendor-linux
@echo ""
@echo "✓ All platforms vendored:"
@echo " macOS ARM64: $(VENDOR_MACOS)/ ($$(ls $(VENDOR_MACOS)/*.whl 2>/dev/null | wc -l | xargs) wheels)"
@echo " Linux x86_64: $(VENDOR_LINUX)/ ($$(ls $(VENDOR_LINUX)/*.whl 2>/dev/null | wc -l | xargs) wheels)"
@echo ""
@echo "Commit with: git add vendor/ && git commit -m 'vendor: update wheels'"
.PHONY: verify-vendor
verify-vendor:
@echo "Testing offline installation from vendor/..."
@bash -c ' \
if [[ "$$OSTYPE" == "darwin"* ]]; then \
PLATFORM="macos-arm64"; \
else \
PLATFORM="linux-x86_64"; \
fi; \
echo "Platform: $$PLATFORM"; \
python3.12 -m venv .venv-verify && \
source .venv-verify/bin/activate && \
pip install --no-index --find-links=vendor/$$PLATFORM -r requirements.txt && \
pytest -v tests/test_vectors.py && \
python src/pyhdwallet.py test && \
echo "" && \
echo "✅ Vendor installation verified!" && \
deactivate && \
rm -rf .venv-verify \
'
# ---------- Binary Building ----------
.PHONY: binary
binary:
@echo "🔨 Building binary for current platform: $(PLATFORM)-$(ARCH)..."
ifeq ($(PLATFORM),macos)
@if [ ! -f "build_binary.sh" ]; then \
echo "❌ build_binary.sh not found!"; \
exit 1; \
fi
@bash build_binary.sh
else
@echo "⚠️ For Linux native builds, use build_binary.sh directly"
@echo " Or use: make binary-linux (builds via Docker)"
endif
.PHONY: binary-linux
binary-linux:
@./build_binary_linux.sh
.PHONY: binary-all
binary-all:
@echo "🏗️ Building binaries for all platforms..."
@echo ""
@echo "1⃣ Building native binary..."
@$(MAKE) binary
@echo ""
@echo "2⃣ Building Linux binary via Docker..."
@$(MAKE) binary-linux
@echo ""
@echo "✅ All binaries built:"
@ls -lh dist/pyhdwallet* 2>/dev/null | awk '{print " " $$9 " (" $$5 ")"}' || echo " No binaries found"
# ---------- Development Workflow ----------
.PHONY: wheels
wheels: requirements.txt build-image
docker run --rm \
-v "$$PWD":$(WORKDIR) \
-w $(WORKDIR) \
$(IMAGE) \
bash -c " \
pip install --upgrade pip setuptools wheel && \
mkdir -p $(WHEELHOUSE) && \
pip wheel -r requirements.txt -w $(WHEELHOUSE) \
"
.PHONY: install
install: requirements.txt
@if [ ! -d "$(VENV_HOST)" ]; then \
echo "Creating venv: $(VENV_HOST)"; \
python3.12 -m venv $(VENV_HOST); \
fi
. $(VENV_HOST)/bin/activate && \
pip install --upgrade pip && \
pip install -r requirements.txt && \
echo "✓ Virtual environment ready: $(VENV_HOST)"
.PHONY: test
test:
@if [ ! -d "$(VENV_HOST)" ]; then \
echo "ERROR: Run 'make install' first"; \
exit 1; \
fi
. $(VENV_HOST)/bin/activate && \
pytest -v tests/test_vectors.py && \
python src/pyhdwallet.py test
# ---------- Warm container lifecycle ----------
.PHONY: up
up: build-image
docker run -dit \
-v "$$PWD":$(WORKDIR) \
-w $(WORKDIR) \
--name $(CONTAINER) \
$(IMAGE) \
bash
.PHONY: shell
shell:
docker exec -it $(CONTAINER) bash
.PHONY: down
down:
- docker rm -f $(CONTAINER)
# ---------- Cleanup ----------
.PHONY: clean-vendor
clean-vendor:
rm -rf vendor/
.PHONY: clean
clean: down
rm -rf $(VENV_HOST) $(WHEELHOUSE) .venv-verify .venv-offline-test
rm -rf dist/ build/ *.spec src/pyhdwallet_frozen.py
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
# NOTE: vendor/ is kept (use 'make clean-vendor' to remove)
# ---------- Platform-Aware Operations ----------
.PHONY: info
info:
@echo "Platform Information:"
@echo " OS: $(PLATFORM)"
@echo " Architecture: $(ARCH)"
@echo " Vendor dir: $(VENDOR_DIR)"
@echo " Python: $(shell python3 --version 2>/dev/null || echo 'not found')"
@echo " Docker: $(shell docker --version 2>/dev/null || echo 'not found')"
# ---------- Release Management ----------
VERSION := $(shell grep 'VERSION = ' src/pyhdwallet.py | head -1 | sed 's/.*"\(.*\)".*/\1/' || echo "v1.1.0")
RELEASE_DIR := releases/$(VERSION)
.PHONY: release-prep
release-prep:
@echo "📦 Preparing release $(VERSION)..."
@mkdir -p $(RELEASE_DIR)
.PHONY: release-binaries
release-binaries: release-prep binary binary-linux
@echo "📦 Copying binaries to $(RELEASE_DIR)..."
@cp dist/pyhdwallet $(RELEASE_DIR)/pyhdwallet-macos-arm64
@cp dist/pyhdwallet-linux $(RELEASE_DIR)/pyhdwallet-linux-x86_64
@echo "✅ Binaries copied"
.PHONY: release-checksums
release-checksums: release-binaries
@echo "🔐 Generating checksums..."
@cd $(RELEASE_DIR) && shasum -a 256 pyhdwallet-* > SHA256SUMS
@echo "✅ Checksums generated:"
@cat $(RELEASE_DIR)/SHA256SUMS
.PHONY: release-vendors
release-vendors: release-prep vendor-all
@echo "📦 Copying vendor wheels to $(RELEASE_DIR)..."
@mkdir -p $(RELEASE_DIR)/vendor
@cp -r vendor/macos-arm64 $(RELEASE_DIR)/vendor/
@cp -r vendor/linux-x86_64 $(RELEASE_DIR)/vendor/
@echo "✅ Vendor wheels copied"
.PHONY: release
release: release-checksums
@echo ""
@echo "🎉 Release $(VERSION) binaries ready!"
@echo ""
@if [ -d ".venv312" ]; then \
echo "📦 Building vendor wheels..."; \
$(MAKE) release-vendors; \
else \
echo "⚠️ Skipping vendor wheels (.venv312 not found)"; \
echo " To include vendors: python3.12 -m venv .venv312 && make release-vendors"; \
fi
@echo ""
@echo "Contents:"
@ls -lh $(RELEASE_DIR)/ | tail -n +2
@echo ""
@cat $(RELEASE_DIR)/SHA256SUMS
@echo ""
@echo "Next steps:"
@echo " 1. Test: make release-test"
@echo " 2. Tag: git tag -a $(VERSION) -m 'Release $(VERSION)'"
@echo " 3. Push: git push origin $(VERSION)"
.PHONY: release-test
release-test:
@echo "🧪 Testing release binaries..."
@echo ""
@echo "Testing macOS binary:"
@$(RELEASE_DIR)/pyhdwallet-macos-arm64 test || echo "⚠️ macOS binary test skipped (wrong platform)"
@echo ""
@echo "Testing Linux binary (via Docker):"
@docker run --rm --platform linux/amd64 \
-v "$$PWD/$(RELEASE_DIR)":/binaries \
ubuntu:22.04 \
/binaries/pyhdwallet-linux-x86_64 test
.PHONY: clean-release
clean-release:
@echo "🧹 Cleaning release artifacts..."
@rm -rf releases/
@echo "✅ Release artifacts cleaned"

283
README.md
View File

@@ -1,31 +1,274 @@
# pyhdwallet v1.0.2
# pyhdwallet Secure HD Wallet Tool
A secure, offline command-line tool for generating and recovering BIP39 HD wallets with support for Ethereum, Solana, and Bitcoin.
A Python command-line tool for generating and recovering BIP39 HD wallets with support for Ethereum, Solana, and Bitcoin. Includes **BIP85 deterministic child mnemonic derivation** for creating multiple isolated wallets from a single master seed. Designed for offline operation with optional PGP encryption and AES-encrypted ZIP artifacts.
## Purpose
---
pyhdwallet helps you create new wallets or recover from existing mnemonics/seeds, with built-in PGP encryption for secure storage. It's designed for privacy-conscious users who want offline, auditable wallet management.
## 📦 Installation
## Installation
### **Quick Start (macOS/Linux with Internet)**
1. Ensure Python 3.11+ is installed.
2. Clone/download the repo.
3. Create a virtual environment: `python -m venv .venv && source .venv/bin/activate`
4. Install dependencies: `pip install -r requirements.txt`
```bash
# Clone repository
git clone https://github.com/<your-username>/hdwalletpy.git
cd hdwalletpy
## Basic Usage
# Install using automated script
./install_offline.sh
```
Run the tool with a subcommand. For help, use `-h`.
The script automatically:
- Generate a wallet: `python ./src/pyhdwallet.py gen --chains ethereum --addresses 3`
- Recover from mnemonic: `python ./src/pyhdwallet.py recover --mnemonic "your words" --chains bitcoin`
- Fetch PGP key: `python ./src/pyhdwallet.py fetchkey "https://example.com/key.asc"`
- Run tests: `python ./src/pyhdwallet.py test`
- Creates Python 3.12 virtual environment
- Installs from vendored wheels (offline-capable)
- Verifies installation with test suite
- Leaves you in activated venv
For detailed examples and security tips, see `playbook.md`.
---
## Security
### **Air-Gapped Installation (No Internet)**
- Operates offline by default.
- Use `--secure-mode` for high-security operations.
- Always verify PGP keys and run on trusted systems.
This repository includes pre-built Python wheels for offline use.
**Supported platforms:**
- macOS ARM64 (M1/M2/M3/M4) - Python 3.12
- Linux x86_64 (Ubuntu/Tails) - Python 3.12
**Steps:**
1. On an online machine, clone and verify:
```bash
git clone https://github.com/<your-username>/hdwalletpy.git
cd hdwalletpy
# Verify checksums
cd vendor/linux-x86_64 # or macos-arm64
sha256sum -c SHA256SUMS # Linux
shasum -a 256 -c SHA256SUMS # macOS
```
2. Transfer entire repo to USB drive
3. On air-gapped machine:
```bash
cd hdwalletpy
./install_offline.sh
```
4. Generate wallet:
```bash
python src/pyhdwallet.py gen --off-screen --file
```
---
### **Developer Installation (with Docker)**
For development or building wheels for other platforms:
```bash
# Build Docker image
make build-image
# Build wheels for all platforms
make vendor-all
# Install development environment
make install
# Run tests
make test
```
---
## ✅ Basic Usage
```bash
# Generate wallet (prints mnemonic - debug mode)
python src/pyhdwallet.py gen
# Generate with off-screen mode + encrypted ZIP
python src/pyhdwallet.py gen --off-screen --file
# Generate with PGP encryption + ZIP
python src/pyhdwallet.py gen \
--pgp-pubkey-file pubkeys/mykey.asc \
--expected-fingerprint A27B96F2B169B5491013D2DA892B822C14A9AA18 \
--off-screen \
--file
# Derive BIP85 child mnemonic (12/15/18/21/24 words)
python src/pyhdwallet.py gen-child \
--interactive \
--words 12 \
--index 0
# Derive BIP85 child with passphrase + encrypted output
python src/pyhdwallet.py gen-child \
--mnemonic-stdin \
--passphrase \
--words 24 \
--index 0 \
--pgp-pubkey-file pubkeys/mykey.asc \
--off-screen \
--file
# Recover wallet from mnemonic
python src/pyhdwallet.py recover --interactive --file
# Fetch PGP public key (requires internet)
python src/pyhdwallet.py fetchkey "https://example.com/key.asc" --out mykey.asc
# Run tests
python src/pyhdwallet.py test
pytest -v tests/test_vectors.py
```
---
## 🔐 Security Features
- **Offline-first**: Network access blocked during key generation/recovery
- **BIP85 deterministic entropy**: Derive unlimited child mnemonics from master seed
- **Test suite**: Regression tests with frozen vectors ensure derivation logic integrity
- **PGP fingerprint pinning**: Prevents key substitution attacks
- **TTY safety guard**: Refuses to print secrets when stdout is piped/redirected
- **AES-encrypted outputs**: Wallet artifacts encrypted with `pyzipper`
- **No shell history leaks**: Use `--interactive` or `--mnemonic-stdin` for recovery
- **Interoperable**: BIP85 children compatible with Coldcard, Ian Coleman tool, etc.
---
## 🛠️ Makefile Targets
**Vendoring (for air-gapped deployment):**
```bash
make vendor-macos # Build macOS ARM64 wheels
make vendor-linux # Build Linux x86_64 wheels (Docker)
make vendor-all # Build for both platforms
make verify-vendor # Test offline installation
```
**Development:**
```bash
make install # Create venv and install dependencies
make test # Run test suite
make build-image # Build Docker image
make shell # Open shell in Docker container
make clean # Remove venvs and build artifacts
```
---
## 📖 Full Documentation
- **[playbook.md](playbook.md)** - Complete command reference and operational guide
- **[tests/](tests/)** - Regression test suite documentation
---
## 🔒 Recommended Air-Gapped Setup
For maximum security when generating production wallets:
1. Use fresh Ubuntu Live USB or Tails OS
2. Never connect to network after booting
3. Transfer this repository via separate USB
4. Run `./install_offline.sh`
5. Generate wallet: `python src/pyhdwallet.py gen --off-screen --file`
6. Write mnemonic to paper/metal backup
7. Transfer encrypted ZIP to secure storage
8. Wipe USB drives securely
See [playbook.md](playbook.md) for detailed air-gapped procedures.
## 🖥️ Platform Compatibility
### Makefile Targets
| Target | macOS | Linux | Notes |
| -------- | ------- | ------- | ------- |
| `make install` | ✅ | ✅ | Create venv + install deps |
| `make test` | ✅ | ✅ | Run test suite |
| `make vendor-macos` | ✅ | ❌ | Requires native macOS ARM64 |
| `make vendor-linux` | ✅ | ✅ | Uses Docker (any OS) |
| `make vendor-all` | ✅ | ⚠️ | Needs macOS for both platforms |
| `make binary` | ✅ | ❌ | Requires `build_binary.sh` |
| `make binary-linux` | ✅ | ✅ | Uses Docker (any OS) |
| `make build-image` | ✅ | ✅ | Docker required |
### Quick Commands by Platform
**macOS:**
```bash
make info # Show platform info
make vendor-current # Build for current platform only
make binary-current # Build binary for current platform
```
**Linux:***
```bash
make info # Show platform info
make vendor-linux # Build Linux wheels
make binary-linux # Build Linux binary
```
***Cross-platform (requires Docker):***
```bash
make vendor-linux # Works on any OS with Docker
make binary-linux # Works on any OS with Docker
```
## Quick Test
```bash
# Check platform detection
make info
# Build for your current platform
make vendor-current
# Or just the targets you need
make install # Works everywhere
make test # Works everywhere
```
## 📦 Binary Distribution
Pre-built standalone binaries are available for convenience (no Python required):
### Download Binary
```bash
# macOS ARM64
curl -LO https://github.com/user/hdwalletpy/releases/download/v1.1.0/pyhdwallet-macos-arm64
chmod +x pyhdwallet-macos-arm64
./pyhdwallet-macos-arm64 test
# Linux x86_64
curl -LO https://github.com/user/hdwalletpy/releases/download/v1.1.0/pyhdwallet-linux
chmod +x pyhdwallet-linux
./pyhdwallet-linux test
```
---
## 🆕 What's New in v1.1.0
- **BIP85 child mnemonic derivation** via new `gen-child` command
- Derive 12/15/18/21/24-word child mnemonics from master seed
- Full interoperability with BIP85-compatible wallets (Coldcard, etc.)
- Optional master BIP39 passphrase support
- Verified against official BIP85 test vectors
- No new dependencies required (uses existing bip-utils + stdlib)
---

View File

@@ -1,655 +0,0 @@
#!/usr/bin/env python3
"""
hdwallet_recovery.py (Python 3.12)
Subcommands:
- fetchkey (online): download ASCII-armored PGP pubkey, print SHA256 + fingerprint
- gen: generate BIP39 mnemonic (12/15/18/24 words) with optional passphrase and dice entropy
- recover (default): derive ADDRESSES ONLY for ETH/SOL/BTC from mnemonic/seed
- optionally encrypt a secret payload using a PGP public key
Security invariant:
- recover never performs network I/O
- fetchkey refuses mnemonic/seed/passphrase inputs
- gen generates secure mnemonics compliant with BIP39
Solana derivation (Mode "A"):
- Derive accounts as: m/44'/501'/{i}'/0'
- Use SLIP-0010 ed25519 hardened derivation from the BIP39 seed. [page:0][page:1]
"""
import argparse
import sys
import json
import getpass
import hashlib
import hmac
import urllib.request
from dataclasses import dataclass
from typing import Dict, List, Any, Optional, Tuple
# -----------------------------------------------------------------------------
# Dependency checks
# -----------------------------------------------------------------------------
def _require(mod: str, pkg: str):
try:
__import__(mod)
except ImportError:
print(f"❌ Missing dependency: {pkg}", file=sys.stderr)
print(f"Install with: pip install {pkg}", file=sys.stderr)
sys.exit(1)
def require_for_fetchkey():
_require("pgpy", "PGPy")
def require_for_derive(export_private: bool, chains: List[str]):
_require("bip_utils", "bip-utils")
_require("pgpy", "PGPy")
# Needed to compute Solana pubkey/address + optional Phantom secret exports
if "solana" in chains:
_require("nacl", "PyNaCl")
_require("base58", "base58")
def get_master_fingerprint(seed_bytes: bytes) -> str:
from bip_utils import Bip32Slip10Secp256k1, Hash160
master = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
pubkey_bytes = master.PublicKey().RawCompressed().ToBytes()
hash160 = Hash160.QuickDigest(pubkey_bytes)
return hash160[:4].hex().upper()
# -----------------------------------------------------------------------------
# PGP helpers (PGPy)
# -----------------------------------------------------------------------------
def pgp_load_pubkey(armored: str):
import pgpy
key, _ = pgpy.PGPKey.from_blob(armored)
if not key.is_public:
raise ValueError("Provided key is not a public key")
return key
def pgp_fingerprint(armored: str) -> str:
return pgp_load_pubkey(armored).fingerprint
def pgp_encrypt_ascii_armored(pubkey_armored: str, payload: Dict[str, Any]) -> str:
import pgpy
pub_key = pgp_load_pubkey(pubkey_armored)
msg = pgpy.PGPMessage.new(json.dumps(payload, indent=2))
enc = pub_key.encrypt(msg)
return str(enc)
# -----------------------------------------------------------------------------
# fetchkey (network allowed ONLY here)
# -----------------------------------------------------------------------------
def fetch_ascii_armored_text(url: str, timeout: int = 15) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "hdwallet-recovery/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
text = data.decode("utf-8", errors="strict")
if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in text:
raise ValueError("Downloaded content does not look like an ASCII-armored PGP public key")
return text
def sha256_hex_text(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int):
require_for_fetchkey()
armored = fetch_ascii_armored_text(url, timeout=timeout)
s256 = sha256_hex_text(armored)
fpr = pgp_fingerprint(armored)
if out_path:
with open(out_path, "w", encoding="utf-8") as f:
f.write(armored)
print("✅ Downloaded PGP public key")
print(f"URL: {url}")
if out_path:
print(f"Saved: {out_path}")
print(f"SHA256: {s256}")
print(f"Fingerprint: {fpr}")
if not out_path:
print("\n-----BEGIN DOWNLOADED KEY-----")
print(armored.strip())
print("-----END DOWNLOADED KEY-----")
# -----------------------------------------------------------------------------
# Derivation
# -----------------------------------------------------------------------------
@dataclass
class AddrOut:
index: int
path: str
address: str
@dataclass
class BtcAddrOut:
index: int
path: str
address_type: str
address: str
def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]:
# Phantom-compatible 64-byte secret key = seed32 || pubkey32 (ed25519).
from nacl.signing import SigningKey
import base58
sk = SigningKey(seed32)
pub32 = sk.verify_key.encode()
secret64 = seed32 + pub32
return {"phantom_base58": base58.b58encode(secret64).decode("ascii")}
# ---- SLIP-0010 ed25519 (matches micro-ed25519-hdkey behavior) ----
# Master key generation for ed25519 is:
# I = HMAC-SHA512(key="ed25519 seed", data=seed)
# k = I_L (32 bytes), c = I_R (32 bytes)
# Child derivation (hardened only):
# I = HMAC-SHA512(key=c_par, data=0x00 || k_par || ser32(i))
# k_i = I_L, c_i = I_R
# This is per SLIP-0010. [page:0]
_ED25519_SEED_KEY = b"ed25519 seed"
def _hmac_sha512(key: bytes, data: bytes) -> bytes:
return hmac.new(key, data, hashlib.sha512).digest()
def _ser32(i: int) -> bytes:
return i.to_bytes(4, "big", signed=False)
def _parse_path_ed25519_hardened(path: str) -> List[int]:
"""
Parse BIP32-like path; for ed25519 we only support hardened indices.
Accept both "m/44'/501'/0'/0'" and "m/44/501/0/0" by promoting to hardened,
matching the "force hardened" convenience described by micro-ed25519-hdkey. [page:1]
"""
if not path.startswith("m/"):
raise ValueError("Path must start with m/")
out: List[int] = []
for comp in path[2:].split("/"):
if comp == "":
continue
hardened = comp.endswith("'")
n_str = comp[:-1] if hardened else comp
if not n_str.isdigit():
raise ValueError(f"Invalid path component: {comp}")
n = int(n_str)
# Promote to hardened (ed25519 hardened-only). [page:0][page:1]
n = (n | 0x80000000)
out.append(n)
return out
def _slip10_ed25519_master(seed_bytes: bytes) -> Tuple[bytes, bytes]:
I = _hmac_sha512(_ED25519_SEED_KEY, seed_bytes)
return I[:32], I[32:]
def _slip10_ed25519_ckd_priv(k_par: bytes, c_par: bytes, index_hardened: int) -> Tuple[bytes, bytes]:
if (index_hardened & 0x80000000) == 0:
raise ValueError("ed25519 SLIP-0010 supports hardened derivation only") # [page:0]
data = b"\x00" + k_par + _ser32(index_hardened)
I = _hmac_sha512(c_par, data)
return I[:32], I[32:]
def solana_seed32_from_bip39_seed_slip10(seed_bytes: bytes, path: str) -> bytes:
"""
Derive 32-byte ed25519 private key bytes (seed) from BIP39 seed bytes using SLIP-0010. [page:0]
"""
idxs = _parse_path_ed25519_hardened(path)
k, c = _slip10_ed25519_master(seed_bytes)
for i in idxs:
k, c = _slip10_ed25519_ckd_priv(k, c, i)
return k
def derive_addresses_and_maybe_secrets(seed_bytes: bytes, chains: List[str], count: int, export_private: bool) -> Dict[str, Any]:
from bip_utils import (
Bip44, Bip44Coins, Bip44Changes,
Bip49, Bip49Coins,
Bip84, Bip84Coins,
)
out: Dict[str, Any] = {"addresses": {}}
if export_private:
out["secrets"] = {}
# ETH (BIP44 m/44'/60'/0'/0/i)
if "ethereum" in chains:
root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
addrs: List[Dict[str, Any]] = []
secrets: List[Dict[str, Any]] = []
for i in range(count):
node = ctx.AddressIndex(i)
addrs.append({"index": i, "path": f"m/44'/60'/0'/0/{i}", "address": node.PublicKey().ToAddress()})
if export_private:
secrets.append({
"index": i,
"path": f"m/44'/60'/0'/0/{i}",
"privkey_hex": node.PrivateKey().Raw().ToHex(),
})
out["addresses"]["ethereum"] = addrs
if export_private and secrets:
out["secrets"]["ethereum"] = secrets
# SOL (Mode "A": m/44'/501'/{i}'/0') derived via SLIP-0010 like micro-ed25519-hdkey. [page:0][page:1]
if "solana" in chains:
from nacl.signing import SigningKey
import base58
addrs: List[Dict[str, Any]] = []
secrets: List[Dict[str, Any]] = []
for i in range(count):
path = f"m/44'/501'/{i}'/0'"
seed32 = solana_seed32_from_bip39_seed_slip10(seed_bytes, path)
sk = SigningKey(seed32)
pub32 = sk.verify_key.encode()
address = base58.b58encode(pub32).decode("ascii")
addrs.append({"index": i, "path": path, "address": address})
if export_private:
secrets.append({
"index": i,
"path": path,
"phantom": _solana_phantom_export_from_seed32(seed32),
})
out["addresses"]["solana"] = addrs
if export_private and secrets:
out["secrets"]["solana"] = secrets
# BTC (addresses only even with --export-private)
if "bitcoin" in chains:
addrs: List[Dict[str, Any]] = []
r44 = Bip44.FromSeed(seed_bytes, Bip44Coins.BITCOIN)
c44 = r44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
r49 = Bip49.FromSeed(seed_bytes, Bip49Coins.BITCOIN)
c49 = r49.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
r84 = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
c84 = r84.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
for i in range(count):
n84 = c84.AddressIndex(i)
n49 = c49.AddressIndex(i)
n44 = c44.AddressIndex(i)
addrs.append({"index": i, "path": f"m/84'/0'/0'/0/{i}", "address_type": "native_segwit", "address": n84.PublicKey().ToAddress()})
addrs.append({"index": i, "path": f"m/49'/0'/0'/0/{i}", "address_type": "segwit", "address": n49.PublicKey().ToAddress()})
addrs.append({"index": i, "path": f"m/44'/0'/0'/0/{i}", "address_type": "legacy", "address": n44.PublicKey().ToAddress()})
out["addresses"]["bitcoin"] = addrs
# Don't create empty secrets dicts
if export_private and "secrets" in out:
out["secrets"] = {k: v for k, v in out["secrets"].items() if v}
if not out["secrets"]:
del out["secrets"]
return out
def format_addresses_human(result: Dict[str, Any]) -> str:
lines: List[str] = []
lines.append("\n" + "=" * 80)
lines.append("MULTI-CHAIN ADDRESS DERIVATION (ADDRESSES ONLY)")
lines.append("=" * 80 + "\n")
for chain, addrs in result["addresses"].items():
lines.append("" * 80)
lines.append(f"{chain.upper()} ADDRESSES")
lines.append("" * 80)
if chain == "bitcoin":
by_type: Dict[str, List[Dict[str, Any]]] = {}
for a in addrs:
by_type.setdefault(a["address_type"], []).append(a)
for t in ["native_segwit", "segwit", "legacy"]:
if t in by_type:
lines.append(f"\n {t.upper()}:")
for a in by_type[t]:
lines.append(f" [{a['index']}] {a['path']}")
lines.append(f"{a['address']}")
else:
for a in addrs:
lines.append(f" [{a['index']}] {a['path']}")
lines.append(f"{a['address']}")
lines.append("")
lines.append("=" * 80 + "\n")
return "\n".join(lines)
def cmd_gen(args):
require_for_derive(False, args.chains)
import secrets
from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator
words_to_entropy = {12: 128, 15: 160, 18: 192, 24: 256}
entropy_bits = words_to_entropy[args.words]
entropy_bytes_len = entropy_bits // 8
if args.dice_rolls:
rolls = args.dice_rolls.strip().split()
if not rolls or not all(r.isdigit() and 1 <= int(r) <= 6 for r in rolls):
raise ValueError("--dice-rolls must be space-separated integers 1-6")
dice_bytes = " ".join(rolls).encode("utf-8")
crypto_bytes = secrets.token_bytes(entropy_bytes_len)
combined = dice_bytes + crypto_bytes
entropy = hashlib.sha256(combined).digest()[:entropy_bytes_len]
else:
entropy = secrets.token_bytes(entropy_bytes_len)
generator = Bip39MnemonicGenerator(Bip39Languages.ENGLISH)
mnemonic = generator.FromEntropy(entropy)
print(f"📍 Generated {args.words}-word BIP39 mnemonic...")
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "")
fingerprint_with = get_master_fingerprint(seed_bytes)
fingerprint_without = None
if args.passphrase:
seed_without = Bip39SeedGenerator(mnemonic).Generate("")
fingerprint_without = get_master_fingerprint(seed_without)
result = derive_addresses_and_maybe_secrets(seed_bytes, args.chains, args.addresses, False)
if args.output == "json":
data = {
"mnemonic": mnemonic,
"passphrase_set": bool(args.passphrase),
"master_fingerprint": fingerprint_with,
"addresses": result["addresses"],
}
if fingerprint_without:
data["master_fingerprint_no_passphrase"] = fingerprint_without
if args.dice_rolls:
data["dice_rolls_used"] = True
out_text = json.dumps(data, indent=2)
else:
fp_text = f"Master Fingerprint: {fingerprint_with}"
if fingerprint_without:
fp_text += f"\nMaster Fingerprint (no passphrase): {fingerprint_without}"
dice_note = "\nDice rolls used for extra entropy: Yes" if args.dice_rolls else ""
out_text = (
f"Generated Mnemonic ({args.words} words):\n{mnemonic}\n\n"
f"Passphrase set: {bool(args.passphrase)}\n{fp_text}{dice_note}\n\n"
+ format_addresses_human(result)
)
print(out_text)
if args.file:
with open(args.file, "w", encoding="utf-8") as f:
f.write(out_text)
print(f"✅ Saved to {args.file}")
if args.pgp_pubkey_file:
with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
pgp_pub = f.read()
payload = {
"version": "v4",
"purpose": "hdwallet generated mnemonic",
"mnemonic": mnemonic,
"passphrase": args.passphrase or "",
"master_fingerprint": fingerprint_with,
"dice_rolls_used": bool(args.dice_rolls),
"addresses": result["addresses"],
}
if fingerprint_without:
payload["master_fingerprint_no_passphrase"] = fingerprint_without
armored = pgp_encrypt_ascii_armored(pgp_pub, payload)
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
print(armored)
print("===== END PGP ENCRYPTED PAYLOAD =====\n")
def cmd_recover(args):
require_for_derive(args.export_private, args.chains)
from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
if args.export_private and not args.pgp_pubkey_file:
raise ValueError("--export-private requires --pgp-pubkey-file (secrets must not go to stdout)")
mnemonic = None
seed_hex = None
if args.interactive:
mode = input("Enter 'm' for mnemonic or 's' for seed: ").strip().lower()
if mode == "m":
mnemonic = getpass.getpass("BIP39 mnemonic (hidden): ").strip()
elif mode == "s":
seed_hex = getpass.getpass("Seed hex (hidden): ").strip()
else:
raise ValueError("Invalid choice")
elif args.mnemonic:
mnemonic = args.mnemonic.strip()
elif args.seed:
seed_hex = args.seed.strip()
else:
raise ValueError("Missing input")
if mnemonic:
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError("Invalid BIP39 mnemonic")
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "")
else:
b = bytes.fromhex(seed_hex)
if len(b) != 64:
raise ValueError(f"Seed must be 64 bytes (128 hex chars), got {len(b)}")
seed_bytes = b
fingerprint_with = get_master_fingerprint(seed_bytes)
fingerprint_without = None
if args.passphrase and mnemonic:
seed_without = Bip39SeedGenerator(mnemonic).Generate("")
fingerprint_without = get_master_fingerprint(seed_without)
print(f"📍 Recovering {len(args.chains)} chain(s), {args.addresses} address(es) each...")
result = derive_addresses_and_maybe_secrets(seed_bytes, args.chains, args.addresses, args.export_private)
# Always print addresses-only output (safe)
if args.output == "json":
data = {"addresses": result["addresses"], "master_fingerprint": fingerprint_with}
if fingerprint_without:
data["master_fingerprint_no_passphrase"] = fingerprint_without
out_text = json.dumps(data, indent=2)
else:
fp_text = f"Master Fingerprint: {fingerprint_with}"
if fingerprint_without:
fp_text += f"\nMaster Fingerprint (no passphrase): {fingerprint_without}"
out_text = fp_text + "\n\n" + format_addresses_human({"addresses": result["addresses"]})
print(out_text)
if args.file:
with open(args.file, "w", encoding="utf-8") as f:
f.write(out_text)
print(f"✅ Addresses saved to {args.file}")
# Optional encrypted payload
if args.pgp_pubkey_file:
with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
pgp_pub = f.read()
payload: Dict[str, Any] = {
"version": "v4",
"purpose": "hdwallet recovery secret payload",
"master_fingerprint": fingerprint_with,
}
if fingerprint_without:
payload["master_fingerprint_no_passphrase"] = fingerprint_without
# Always include mnemonic/seed_hex if present (your requirement)
if mnemonic:
payload["mnemonic"] = mnemonic
else:
payload["seed_hex"] = seed_hex
if args.export_private:
payload["passphrase_set"] = bool(args.passphrase)
payload["passphrase_hint"] = args.passphrase_hint or ""
payload["note"] = "Private keys were derived from mnemonic/seed + (optional) passphrase. Passphrase value is intentionally omitted."
payload["derived_private_keys"] = result.get("secrets", {})
payload["addresses"] = result["addresses"]
else:
payload["passphrase"] = args.passphrase or ""
armored = pgp_encrypt_ascii_armored(pgp_pub, payload)
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
print(armored)
print("===== END PGP ENCRYPTED PAYLOAD =====\n")
def cmd_test(args):
with NetworkGuard("test"):
require_for_offline(["ethereum", "solana"])
from bip_utils import Bip39SeedGenerator
print("🧪 Running tests...")
# --- Existing vector (yours) ---
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
passphrase = "" # empty
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
# --- NEW: Solana path->address test ---
expected_addr = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
path = "m/44'/501'/0'/0'"
seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) # or slip10_ed25519_seed32_by_path(...)
got_addr = sol_pubkey_b58_from_seed32(seed32)
if got_addr != expected_addr:
raise RuntimeError(f"Solana address mismatch for {path}: got {got_addr}, expected {expected_addr}")
print(f"✅ Solana OK: {path} => {got_addr}")
print("✅ All tests passed")
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="HD wallet recovery + fetchkey + mnemonic generation")
subparsers = parser.add_subparsers(dest="cmd")
p_fetch = subparsers.add_parser("fetchkey", help="Download ASCII-armored PGP pubkey from URL")
p_fetch.add_argument("url", help="URL to fetch (e.g., https://github.com/<user>.gpg)")
p_fetch.add_argument("--out", help="Write key to file (recommended)", default=None)
p_fetch.add_argument("--timeout", type=int, default=15, help="HTTP timeout seconds")
p_test = subparsers.add_parser("test", help="Run tests against Trezor BIP39/BIP32 vectors")
p_gen = subparsers.add_parser("gen", help="Generate BIP39 mnemonic with optional dice entropy")
p_recover = subparsers.add_parser("recover", help="Derive addresses from mnemonic/seed")
# Common args for gen and recover
for p in [p_gen, p_recover, parser]:
p.add_argument("--passphrase", default="", help="BIP39 passphrase (optional)")
p.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"],
default=["ethereum", "solana", "bitcoin"])
p.add_argument("--addresses", type=int, default=5)
p.add_argument("--output", choices=["text", "json"], default="text")
p.add_argument("--file", help="Save output to file", default=None)
p.add_argument("--pgp-pubkey-file", help="ASCII-armored PGP public key file to encrypt payload", default=None)
# Gen specific
p_gen.add_argument("--words", type=int, choices=[12, 15, 18, 24], default=12, help="Number of words for mnemonic")
p_gen.add_argument("--dice-rolls", help="Space-separated die rolls (1-6) for extra entropy")
# Recover specific (default parser args, so recover works without subcommand too)
parser.add_argument("--mnemonic", help="BIP39 mnemonic (12/24 words)")
parser.add_argument("--seed", help="64-byte seed hex (128 hex chars)")
parser.add_argument("--interactive", action="store_true", help="Prompt for input via stdin")
parser.add_argument("--passphrase-hint", default="", help="Hint/reminder for passphrase (stored only in encrypted payload when --export-private)")
parser.add_argument("--export-private", action="store_true",
help="Encrypt derived private keys into the PGP payload (never printed). Requires --pgp-pubkey-file.")
args = parser.parse_args()
try:
if args.cmd == "fetchkey":
forbidden = any([
getattr(args, "mnemonic", None),
getattr(args, "seed", None),
getattr(args, "interactive", False),
getattr(args, "passphrase", ""),
getattr(args, "passphrase_hint", ""),
getattr(args, "pgp_pubkey_file", None),
getattr(args, "export_private", False),
getattr(args, "words", 12) != 12,
getattr(args, "dice_rolls", None),
])
if forbidden:
raise ValueError("fetchkey mode must not be used with mnemonic/seed/passphrase/export-private/words/dice-rolls options")
cmd_fetchkey(args.url, args.out, args.timeout)
return
elif args.cmd == "gen":
if any([
getattr(args, "mnemonic", None),
getattr(args, "seed", None),
getattr(args, "interactive", False),
getattr(args, "export_private", False),
getattr(args, "passphrase_hint", ""),
]):
raise ValueError("gen mode must not be used with mnemonic/seed/interactive/export-private/passphrase-hint options")
cmd_gen(args)
return
elif args.cmd == "test":
cmd_test(args)
return
# recover (default or explicit)
if not (args.mnemonic or args.seed or args.interactive):
raise ValueError("Provide --mnemonic or --seed or --interactive (or use subcommand gen/fetchkey)")
cmd_recover(args)
except Exception as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,520 +0,0 @@
#!/usr/bin/env python3
"""
mypywallet.py (Python 3.12)
Offline HD wallet gen/recover for ETH/SOL/BTC + optional online fetchkey.
Solana profiles:
- js_ed25519_hd_key (DEFAULT): fixed path m/44'/501'/0'/0' (matches common JS examples using ed25519-hd-key derivePath)
- phantom_bip44change: m/44'/501'/{index}'/0' (Phantom bip44Change grouping) [web:7]
- phantom_bip44: m/44'/501'/{index}' (Phantom bip44 grouping) [web:7]
- phantom_deprecated: m/501'/{index}'/0/0 (Phantom deprecated) [web:7]
- solana_bip39_first32: seed[0:32] (Solana Cookbook "BIP39 format mnemonics") [web:47]
Notes:
- Some Ed25519 derivation libs do not support non-hardened indices; for phantom_deprecated we try
m/501'/{i}'/0/0 first, then fallback to m/501'/{i}'/0'/0' if required.
"""
import argparse
import sys
import json
import getpass
import hashlib
import urllib.request
from typing import Dict, List, Any, Optional, Tuple
# -----------------------------------------------------------------------------
# Dependency checks
# -----------------------------------------------------------------------------
def _require(mod: str, pkg: str):
try:
__import__(mod)
except ImportError:
print(f"❌ Missing dependency: {pkg}", file=sys.stderr)
print(f"Install with: pip install {pkg}", file=sys.stderr)
sys.exit(1)
def require_for_fetchkey():
_require("pgpy", "PGPy")
def require_for_derive(export_private: bool, chains: List[str]):
_require("bip_utils", "bip-utils")
_require("base58", "base58")
_require("nacl", "PyNaCl")
_require("pgpy", "PGPy")
# -----------------------------------------------------------------------------
# Offline network guard
# -----------------------------------------------------------------------------
class NetworkGuard:
def __init__(self, mode_name: str):
self.mode_name = mode_name
self._orig = None
def __enter__(self):
self._orig = urllib.request.urlopen
def blocked_urlopen(*args, **kwargs):
raise RuntimeError(f"Network I/O is disabled in {self.mode_name} mode")
urllib.request.urlopen = blocked_urlopen
return self
def __exit__(self, exc_type, exc, tb):
urllib.request.urlopen = self._orig
return False
# -----------------------------------------------------------------------------
# BIP32 master fingerprint
# -----------------------------------------------------------------------------
def get_master_fingerprint(seed_bytes: bytes) -> str:
from bip_utils import Bip32Slip10Secp256k1, Hash160
master = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
pubkey_bytes = master.PublicKey().RawCompressed().ToBytes()
h160 = Hash160.QuickDigest(pubkey_bytes)
return h160[:4].hex().upper()
# -----------------------------------------------------------------------------
# PGP helpers (PGPy)
# -----------------------------------------------------------------------------
def pgp_load_pubkey(armored: str):
import pgpy
key, _ = pgpy.PGPKey.from_blob(armored)
if not key.is_public:
raise ValueError("Provided key is not a public key")
return key
def pgp_fingerprint(armored: str) -> str:
return pgp_load_pubkey(armored).fingerprint
def pgp_encrypt_ascii_armored(pubkey_armored: str, payload: Dict[str, Any], ignore_usage_flags: bool = False) -> str:
import pgpy
pub_key = pgpy.PGPKey.from_blob(pubkey_armored)[0]
if ignore_usage_flags:
pub_key._require_usage_flags = False
msg = pgpy.PGPMessage.new(json.dumps(payload, indent=2, ensure_ascii=False))
enc = pub_key.encrypt(msg)
return str(enc)
# -----------------------------------------------------------------------------
# fetchkey (network allowed ONLY here)
# -----------------------------------------------------------------------------
def fetch_ascii_armored_text(url: str, timeout: int = 15) -> Tuple[str, bytes]:
req = urllib.request.Request(url, headers={"User-Agent": "mypywallet/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
text = data.decode("utf-8", errors="strict")
if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in text:
raise ValueError("Downloaded content does not look like an ASCII-armored PGP public key")
return text, data
def sha256_hex_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int):
require_for_fetchkey()
armored_text, data = fetch_ascii_armored_text(url, timeout=timeout)
s256 = sha256_hex_bytes(data)
fpr = pgp_fingerprint(armored_text)
if out_path:
with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(armored_text)
print("✅ Downloaded PGP public key")
print(f"URL: {url}")
if out_path:
print(f"Saved: {out_path}")
print(f"SHA256: {s256}")
print(f"Fingerprint: {fpr}")
# -----------------------------------------------------------------------------
# Solana helpers
# -----------------------------------------------------------------------------
def sol_pubkey_b58_from_seed32(seed32: bytes) -> str:
from nacl.signing import SigningKey
import base58
sk = SigningKey(seed32)
pub32 = sk.verify_key.encode()
return base58.b58encode(pub32).decode("ascii")
def sol_secret64_b58_from_seed32(seed32: bytes) -> str:
# Phantom-compatible 64-byte secret = seed32 || pubkey32. [web:7]
from nacl.signing import SigningKey
import base58
sk = SigningKey(seed32)
pub32 = sk.verify_key.encode()
secret64 = seed32 + pub32
return base58.b58encode(secret64).decode("ascii")
def derive_ed25519_seed32_by_path(seed_bytes: bytes, path: str) -> bytes:
from bip_utils import Bip32Slip10Ed25519
bip32 = Bip32Slip10Ed25519.FromSeedAndPath(seed_bytes, path)
seed32 = bip32.PrivateKey().Raw().ToBytes()
if len(seed32) != 32:
raise ValueError(f"Unexpected derived private key length: {len(seed32)}")
return seed32
def sol_seed32_phantom_deprecated(seed_bytes: bytes, index: int) -> Tuple[str, bytes]:
# Phantom documents deprecated structure m/501'/{index}'/0/0. [web:7]
path1 = f"m/501'/{index}'/0/0"
try:
return path1, derive_ed25519_seed32_by_path(seed_bytes, path1)
except Exception:
# Fallback for libs that reject non-hardened indices under Ed25519 derivation.
path2 = f"m/501'/{index}'/0'/0'"
return path2, derive_ed25519_seed32_by_path(seed_bytes, path2)
# -----------------------------------------------------------------------------
# Derivation core
# -----------------------------------------------------------------------------
def derive_addresses(
seed_bytes: bytes,
chains: List[str],
count: int,
sol_profile: str,
sol_match: str = "",
sol_scan: int = 50,
export_private: bool = False,
) -> Dict[str, Any]:
from bip_utils import (
Bip44, Bip44Coins, Bip44Changes,
Bip49, Bip49Coins,
Bip84, Bip84Coins,
)
out: Dict[str, Any] = {"addresses": {}}
if export_private:
out["secrets"] = {}
# ETH
if "ethereum" in chains:
root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
out["addresses"]["ethereum"] = [
{"index": i, "path": f"m/44'/60'/0'/0/{i}", "address": ctx.AddressIndex(i).PublicKey().ToAddress()}
for i in range(count)
]
# BTC
if "bitcoin" in chains:
addrs = []
r44 = Bip44.FromSeed(seed_bytes, Bip44Coins.BITCOIN)
c44 = r44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
r49 = Bip49.FromSeed(seed_bytes, Bip49Coins.BITCOIN)
c49 = r49.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
r84 = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
c84 = r84.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
for i in range(count):
addrs.append({"index": i, "path": f"m/84'/0'/0'/0/{i}", "address_type": "native_segwit", "address": c84.AddressIndex(i).PublicKey().ToAddress()})
addrs.append({"index": i, "path": f"m/49'/0'/0'/0/{i}", "address_type": "segwit", "address": c49.AddressIndex(i).PublicKey().ToAddress()})
addrs.append({"index": i, "path": f"m/44'/0'/0'/0/{i}", "address_type": "legacy", "address": c44.AddressIndex(i).PublicKey().ToAddress()})
out["addresses"]["bitcoin"] = addrs
# SOL
if "solana" in chains:
from bip_utils import Bip44, Bip44Coins, Bip44Changes
root = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
base = root.Purpose().Coin()
def derive_one(profile: str, i: int) -> Tuple[str, str, bytes]:
# returns (path, address, seed32)
if profile == "js_ed25519_hd_key":
# Fixed path: m/44'/501'/0'/0' (JS derivePath examples)
path = "m/44'/501'/0'/0'"
seed32 = derive_ed25519_seed32_by_path(seed_bytes, path)
addr = sol_pubkey_b58_from_seed32(seed32)
return path, addr, seed32
if profile == "phantom_bip44change":
path = f"m/44'/501'/{i}'/0'"
seed32 = derive_ed25519_seed32_by_path(seed_bytes, path)
addr = sol_pubkey_b58_from_seed32(seed32)
return path, addr, seed32
if profile == "phantom_bip44":
path = f"m/44'/501'/{i}'"
seed32 = derive_ed25519_seed32_by_path(seed_bytes, path)
addr = sol_pubkey_b58_from_seed32(seed32)
return path, addr, seed32
if profile == "phantom_deprecated":
# m/501'/{index}'/0/0 [web:7]
path, seed32 = sol_seed32_phantom_deprecated(seed_bytes, i)
addr = sol_pubkey_b58_from_seed32(seed32)
return path, addr, seed32
if profile == "solana_bip39_first32":
# Solana Cookbook: seed[0:32] [web:47]
seed32 = seed_bytes[:32]
addr = sol_pubkey_b58_from_seed32(seed32)
return "BIP39 seed[0:32]", addr, seed32
raise ValueError(f"Unknown Solana profile: {profile}")
# Profiles that only yield a single address (fixed seed/path)
if sol_profile in ("solana_bip39_first32", "js_ed25519_hd_key"):
count = 1
if sol_match:
profiles = ["js_ed25519_hd_key", "phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"]
for prof in profiles:
max_i = 1 if prof in ("solana_bip39_first32", "js_ed25519_hd_key") else sol_scan
for i in range(max_i):
path, addr, _seed32 = derive_one(prof, i)
if addr == sol_match:
out["addresses"]["solana_match"] = [{"profile": prof, "index": i, "path": path, "address": addr}]
return out
out["addresses"]["solana_match"] = []
return out
addrs = []
secrets = []
for i in range(count):
path, addr, seed32 = derive_one(sol_profile, i)
addrs.append({"index": i, "path": path, "address": addr})
if export_private:
secrets.append({"index": i, "path": path, "phantom_secret_base58": sol_secret64_b58_from_seed32(seed32)})
out["addresses"]["solana"] = addrs
if export_private:
out["secrets"]["solana"] = secrets
if export_private:
out["secrets"] = {k: v for k, v in out["secrets"].items() if v}
if not out["secrets"]:
del out["secrets"]
return out
def format_addresses_human(addresses: Dict[str, Any]) -> str:
lines: List[str] = []
lines.append("\n" + "=" * 80)
lines.append("MULTI-CHAIN ADDRESS DERIVATION (ADDRESSES ONLY)")
lines.append("=" * 80 + "\n")
for chain, addrs in addresses.items():
lines.append("" * 80)
lines.append(f"{chain.upper()} ADDRESSES")
lines.append("" * 80)
if chain == "bitcoin":
by_type: Dict[str, List[Dict[str, Any]]] = {}
for a in addrs:
by_type.setdefault(a["address_type"], []).append(a)
for t in ["native_segwit", "segwit", "legacy"]:
if t in by_type:
lines.append(f"\n {t.upper()}:")
for a in by_type[t]:
lines.append(f" [{a['index']}] {a['path']}")
lines.append(f"{a['address']}")
else:
for a in addrs:
prof = a.get("profile")
if prof:
lines.append(f" [{a.get('index', 0)}] {a['path']} (profile={prof})")
else:
lines.append(f" [{a.get('index', 0)}] {a['path']}")
lines.append(f"{a['address']}")
lines.append("")
lines.append("=" * 80 + "\n")
return "\n".join(lines)
# -----------------------------------------------------------------------------
# Commands
# -----------------------------------------------------------------------------
def cmd_gen(args):
with NetworkGuard("gen"):
require_for_derive(False, args.chains)
import secrets
from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator
words_to_entropy = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256}
entropy_len = (words_to_entropy[args.words] // 8)
if args.dice_rolls:
rolls = args.dice_rolls.strip().split()
if not rolls or not all(r.isdigit() and 1 <= int(r) <= 6 for r in rolls):
raise ValueError("--dice-rolls must be space-separated integers 1-6")
dice_bytes = (" ".join(rolls)).encode("utf-8")
crypto_bytes = secrets.token_bytes(entropy_len)
entropy = hashlib.sha256(dice_bytes + crypto_bytes).digest()[:entropy_len]
else:
entropy = secrets.token_bytes(entropy_len)
mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(entropy)
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "")
fp_with = get_master_fingerprint(seed_bytes)
print(f"📍 Generated {args.words}-word BIP39 mnemonic.")
if args.unsafe_print:
print(f"\nMnemonic:\n{mnemonic}\n")
result = derive_addresses(seed_bytes, args.chains, args.addresses, args.sol_profile)
if args.output == "json":
out_text = json.dumps({
"master_fingerprint": fp_with,
"solana_profile": args.sol_profile,
"addresses": result["addresses"],
}, indent=2, ensure_ascii=False)
else:
out_text = f"Master Fingerprint: {fp_with}\n\n" + format_addresses_human(result["addresses"])
print(out_text)
def cmd_recover(args):
with NetworkGuard("recover"):
require_for_derive(args.export_private, args.chains)
from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
mnemonic = None
seed_hex = None
if args.interactive:
mode = input("Enter 'm' for mnemonic or 's' for seed: ").strip().lower()
if mode == "m":
mnemonic = getpass.getpass("BIP39 mnemonic (hidden): ").strip()
elif mode == "s":
seed_hex = getpass.getpass("Seed hex (hidden, 128 hex chars): ").strip()
else:
raise ValueError("Invalid choice")
elif args.mnemonic:
mnemonic = args.mnemonic.strip()
elif args.seed:
seed_hex = args.seed.strip()
else:
raise ValueError("Missing input")
if mnemonic:
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError("Invalid BIP39 mnemonic")
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "")
else:
b = bytes.fromhex(seed_hex)
if len(b) != 64:
raise ValueError(f"Seed must be 64 bytes (128 hex chars), got {len(b)} bytes")
seed_bytes = b
fp_with = get_master_fingerprint(seed_bytes)
print(f"📍 Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...")
print(f"Master Fingerprint: {fp_with}\n")
result = derive_addresses(
seed_bytes=seed_bytes,
chains=args.chains,
count=args.addresses,
sol_profile=args.sol_profile,
sol_match=(args.sol_match or "").strip(),
sol_scan=args.sol_scan,
export_private=args.export_private,
)
if args.output == "json":
out_text = json.dumps({
"master_fingerprint": fp_with,
"solana_profile": args.sol_profile,
"solana_match": (args.sol_match or "").strip(),
"addresses": result["addresses"],
}, indent=2, ensure_ascii=False)
else:
out_text = format_addresses_human(result["addresses"])
print(out_text)
def main():
parser = argparse.ArgumentParser(description="Offline HD wallet gen/recover + online fetchkey")
subparsers = parser.add_subparsers(dest="cmd")
p_fetch = subparsers.add_parser("fetchkey", help="Download ASCII-armored PGP pubkey from URL (online)")
p_fetch.add_argument("url", help="URL to fetch (e.g., https://github.com/<user>.gpg)")
p_fetch.add_argument("--out", help="Write key to file (recommended)", default=None)
p_fetch.add_argument("--timeout", type=int, default=15, help="HTTP timeout seconds")
p_gen = subparsers.add_parser("gen", help="Generate BIP39 mnemonic (offline)")
p_recover = subparsers.add_parser("recover", help="Recover addresses from mnemonic/seed (offline)")
for p in [p_gen, p_recover]:
p.add_argument("--passphrase", default="", help="BIP39 passphrase (optional)")
p.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"],
default=["ethereum", "solana", "bitcoin"])
p.add_argument("--addresses", type=int, default=5)
p.add_argument("--output", choices=["text", "json"], default="text")
p.add_argument("--sol-profile",
choices=["js_ed25519_hd_key", "phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"],
default="js_ed25519_hd_key",
help="Solana derivation behavior; Phantom supports multiple groupings; Solana cookbook also shows a BIP39-first32 shortcut. [web:7][web:47]")
p.add_argument("--export-private", action="store_true",
help="Print Solana secret keys (base58) to stdout (danger).")
p_gen.add_argument("--words", type=int, choices=[12, 15, 18, 21, 24], default=12)
p_gen.add_argument("--dice-rolls", default="")
p_gen.add_argument("--unsafe-print", action="store_true")
p_recover.add_argument("--mnemonic", default="")
p_recover.add_argument("--seed", default="")
p_recover.add_argument("--interactive", action="store_true")
p_recover.add_argument("--sol-match", default="",
help="Search for this Solana address across supported Solana profiles. [web:7]")
p_recover.add_argument("--sol-scan", type=int, default=50,
help="How many indices to scan when using --sol-match.")
args = parser.parse_args()
try:
if args.cmd == "fetchkey":
cmd_fetchkey(args.url, args.out, args.timeout)
return
if args.cmd == "gen":
cmd_gen(args)
return
if args.cmd == "recover":
cmd_recover(args)
return
parser.print_help()
sys.exit(2)
except Exception as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

115
build-all-platforms.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/bin/bash
set -e
echo "Building wheels for all platforms using Python 3.12..."
echo ""
# Check Docker is running
if ! docker info > /dev/null 2>&1; then
echo "ERROR: Docker is not running. Please start Docker Desktop."
exit 1
fi
# 1. macOS ARM64 (via Docker with Python 3.12)
echo "==> Building macOS ARM64 wheels (Docker + Python 3.12)..."
docker run --rm \
--platform linux/arm64 \
-v $(pwd):/work \
-w /work \
python:3.12-slim \
bash -c "
apt-get update -qq && apt-get install -y -qq gcc g++ make libffi-dev > /dev/null
pip install --upgrade pip > /dev/null
mkdir -p vendor/macos-arm64-docker
pip download --dest vendor/macos-arm64-docker -r requirements.txt
pip wheel --wheel-dir vendor/macos-arm64-docker --no-deps vendor/macos-arm64-docker/*.tar.gz 2>/dev/null || true
rm -f vendor/macos-arm64-docker/*.tar.gz
"
echo "✓ macOS ARM64: $(ls vendor/macos-arm64-docker/*.whl 2>/dev/null | wc -l | xargs) wheels"
# Actually, let's use native Mac Python 3.12 if available, otherwise use what we have
echo ""
echo "==> Building macOS ARM64 wheels (native - using your .venv312)..."
mkdir -p vendor/macos-arm64
# Use the Python from your working venv
if [ -f ".venv312/bin/pip" ]; then
echo "Using Python from .venv312..."
.venv312/bin/pip download --dest vendor/macos-arm64 -r requirements.txt
echo "✓ macOS ARM64: $(ls vendor/macos-arm64/*.whl 2>/dev/null | wc -l | xargs) wheels"
else
echo "ERROR: .venv312 not found!"
echo "Please activate your working venv first:"
echo " source .venv312/bin/activate"
echo " pip download --dest vendor/macos-arm64 -r requirements.txt"
exit 1
fi
# 2. Linux x86_64 (via Docker)
echo ""
echo "==> Building Linux x86_64 wheels (Docker + Python 3.12)..."
docker run --rm \
-v $(pwd):/work \
-w /work \
python:3.12-slim \
bash -c "
apt-get update -qq && apt-get install -y -qq gcc g++ make libffi-dev > /dev/null
pip install --upgrade pip > /dev/null
mkdir -p vendor/linux-x86_64
pip download --dest vendor/linux-x86_64 -r requirements.txt
pip wheel --wheel-dir vendor/linux-x86_64 --no-deps vendor/linux-x86_64/*.tar.gz 2>/dev/null || true
rm -f vendor/linux-x86_64/*.tar.gz
"
echo "✓ Linux x86_64: $(ls vendor/linux-x86_64/*.whl 2>/dev/null | wc -l | xargs) wheels"
# 3. Generate SHA256 checksums
echo ""
echo "==> Generating checksums..."
for platform in macos-arm64 linux-x86_64; do
if [ -d "vendor/$platform" ] && [ "$(ls -A vendor/$platform/*.whl 2>/dev/null)" ]; then
(cd vendor/$platform && shasum -a 256 *.whl > SHA256SUMS)
echo "✓ Checksums for $platform"
fi
done
# 4. Create provenance file
echo ""
echo "==> Creating provenance documentation..."
cat > vendor/PROVENANCE.md << EOF
# Dependency Provenance
All wheels generated on: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
Python version: 3.12
Build machine: $(uname -s) $(uname -m)
Docker used: Yes (python:3.12-slim)
## Platforms
- macOS ARM64: Built using .venv312 (Python 3.12)
- Linux x86_64: Built using Docker python:3.12-slim
## Package Versions
$(cat requirements.txt | head -20)
## Integrity Verification
Each platform directory contains SHA256SUMS for verification:
\`\`\`bash
cd vendor/linux-x86_64
shasum -a 256 -c SHA256SUMS
\`\`\`
Last updated: $(date -u +"%Y-%m-%d")
Built by: $(git config user.name) <$(git config user.email)>
Commit: $(git rev-parse --short HEAD 2>/dev/null || echo "not in git")
EOF
echo ""
echo "✓ All platforms built successfully!"
echo ""
echo "Summary:"
echo " macOS ARM64: vendor/macos-arm64/ ($(ls vendor/macos-arm64/*.whl 2>/dev/null | wc -l | xargs) wheels)"
echo " Linux x86_64: vendor/linux-x86_64/ ($(ls vendor/linux-x86_64/*.whl 2>/dev/null | wc -l | xargs) wheels)"
echo ""
echo "Next steps:"
echo " 1. Verify: ls vendor/*/SHA256SUMS"
echo " 2. Test: ./install_offline.sh"
echo " 3. Commit: git add vendor/ && git commit -m 'vendor: add multi-platform wheels'"

76
build_binary.sh Executable file
View File

@@ -0,0 +1,76 @@
#!/bin/bash
set -e
echo "🔨 Building pyhdwallet standalone binary..."
# Check venv more strictly
if [ -z "$VIRTUAL_ENV" ] || [[ "$VIRTUAL_ENV" != *"hdwalletpy"* ]]; then
echo "❌ Not in virtual environment!"
echo ""
echo "Activate it first:"
echo " source .venv/bin/activate"
exit 1
fi
# Install PyInstaller if needed
if ! pip show pyinstaller &>/dev/null; then
echo "📦 Installing PyInstaller..."
pip install pyinstaller
fi
# Create patched version for frozen builds
echo "📝 Creating frozen-compatible version..."
python3 << 'PATCH'
with open('src/pyhdwallet.py', 'r') as f:
content = f.read()
# Patch _require() for PyInstaller
content = content.replace(
"""def _require(mod: str, pkg: str) -> None:
try:
__import__(mod)""",
"""def _require(mod: str, pkg: str) -> None:
# Skip check in PyInstaller frozen executable
if getattr(sys, 'frozen', False):
return
try:
__import__(mod)"""
)
with open('src/pyhdwallet_frozen.py', 'w') as f:
f.write(content)
PATCH
# Clean previous builds
rm -rf build/ dist/ *.spec
# Build
echo "🔧 Building with PyInstaller..."
pyinstaller --onefile \
--name pyhdwallet \
--clean \
--collect-all bip_utils \
--collect-all pgpy \
--collect-all nacl \
--collect-all pyzipper \
--collect-all coincurve \
--copy-metadata coincurve \
src/pyhdwallet_frozen.py
# Clean up temp file
rm src/pyhdwallet_frozen.py
# Test
echo ""
echo "✅ Testing binary..."
./dist/pyhdwallet test
# Show results
echo ""
echo "🎉 Binary created successfully!"
echo " Location: dist/pyhdwallet"
echo " Size: $(du -h dist/pyhdwallet | cut -f1)"
echo ""
echo "Quick test:"
echo " ./dist/pyhdwallet gen --help"
echo " ./dist/pyhdwallet gen-child --help"

70
build_binary_linux.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
set -e
echo "🐧 Building Linux x86_64 binary via Docker (Ubuntu 22.04 base)..."
mkdir -p dist
docker run --rm \
--platform linux/amd64 \
-v "$PWD":/work \
-w /work \
ubuntu:22.04 \
bash -c '
set -e
export DEBIAN_FRONTEND=noninteractive
# Install Python 3.12 and build tools
apt-get update -qq
apt-get install -y -qq software-properties-common
add-apt-repository -y ppa:deadsnakes/ppa
apt-get update -qq
apt-get install -y -qq \
python3.12 \
python3.12-venv \
python3.12-dev \
binutils \
gcc \
g++ \
make \
libffi-dev
# Install pip for Python 3.12
python3.12 -m ensurepip
python3.12 -m pip install -q --upgrade pip
python3.12 -m pip install -q pyinstaller
python3.12 -m pip install -q -r requirements.txt
echo "📝 Patching for frozen build..."
python3.12 -c "
import sys
with open(\"src/pyhdwallet.py\", \"r\") as f:
content = f.read()
content = content.replace(
\"def _require(mod: str, pkg: str) -> None:\\n try:\",
\"def _require(mod: str, pkg: str) -> None:\\n import sys\\n if getattr(sys, \\\"frozen\\\", False): return\\n try:\"
)
with open(\"/tmp/pyhdwallet_frozen.py\", \"w\") as f:
f.write(content)
"
echo "🔧 Building with PyInstaller..."
python3.12 -m PyInstaller --onefile --name pyhdwallet-linux --clean \
--collect-all bip_utils \
--collect-all pgpy \
--collect-all nacl \
--collect-all pyzipper \
--collect-all coincurve \
--copy-metadata coincurve \
/tmp/pyhdwallet_frozen.py
echo "✅ Linux binary created (compatible with Ubuntu 22.04+)"
'
echo ""
echo "Binary location: dist/pyhdwallet-linux"
echo "Size: $(du -h dist/pyhdwallet-linux 2>/dev/null | cut -f1 || echo 'N/A')"
echo "GLIBC: $(docker run --rm --platform linux/amd64 -v "$PWD/dist":/b ubuntu:22.04 ldd /b/pyhdwallet-linux | grep libc.so || echo 'static')"
echo ""
echo "Test it:"
echo " docker run --rm --platform linux/amd64 -v \"\$PWD/dist\":/bin ubuntu:22.04 /bin/pyhdwallet-linux test"

117
install_offline.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/bin/bash
set -e
# Detect platform
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
PLATFORM="linux-x86_64"
elif [[ "$OSTYPE" == "darwin"* ]]; then
ARCH=$(uname -m)
if [[ "$ARCH" == "arm64" ]]; then
PLATFORM="macos-arm64"
else
echo "ERROR: Only macOS ARM64 is supported"
exit 1
fi
else
echo "ERROR: Unsupported platform: $OSTYPE"
exit 1
fi
echo "Detected platform: $PLATFORM"
# Check if vendor directory exists
if [ ! -d "vendor/$PLATFORM" ]; then
echo "ERROR: vendor/$PLATFORM not found!"
echo "Run ./build-vendors-simple.sh to generate wheels"
exit 1
fi
# Check for Python 3.12
if ! command -v python3.12 &> /dev/null; then
echo "ERROR: Python 3.12 not found!"
echo "Please install Python 3.12:"
echo " macOS: brew install python@3.12"
echo " Linux: apt-get install python3.12 python3.12-venv"
exit 1
fi
# Check if already in a venv
if [ -n "$VIRTUAL_ENV" ]; then
CURRENT_PYTHON=$(python --version 2>&1 | grep -oE '3\.[0-9]+')
if [[ "$CURRENT_PYTHON" != "3.12" ]]; then
echo "WARNING: You are in a venv with Python $CURRENT_PYTHON"
echo "Deactivating and creating new Python 3.12 venv..."
deactivate || true
else
echo "Already in Python 3.12 venv, using it..."
fi
fi
# Create or use existing venv
VENV_DIR=".venv"
if [ ! -d "$VENV_DIR" ] || [ ! -f "$VENV_DIR/bin/python3.12" ]; then
echo "Creating Python 3.12 virtual environment..."
python3.12 -m venv "$VENV_DIR"
echo "✓ Virtual environment created: $VENV_DIR"
else
echo "Using existing venv: $VENV_DIR"
fi
# Activate venv
source "$VENV_DIR/bin/activate"
# Verify Python version
PYTHON_VERSION=$(python --version 2>&1 | grep -oE '3\.[0-9]+')
echo "Using Python $PYTHON_VERSION from: $(which python)"
if [[ "$PYTHON_VERSION" != "3.12" ]]; then
echo "ERROR: Expected Python 3.12, got $PYTHON_VERSION"
exit 1
fi
# Verify checksums
if [ -f "vendor/$PLATFORM/SHA256SUMS" ]; then
echo ""
echo "Verifying checksums..."
(cd vendor/$PLATFORM && shasum -a 256 -c SHA256SUMS --quiet) || {
echo "ERROR: Checksum verification failed!"
exit 1
}
echo "✓ Checksums verified"
fi
# Install
echo ""
echo "Installing from vendor/$PLATFORM..."
pip install --upgrade pip --quiet
pip install --no-index --find-links=vendor/$PLATFORM -r requirements.txt
# Test
echo ""
echo "Running tests..."
python -m pytest -v tests/test_vectors.py || {
echo "ERROR: Tests failed!"
exit 1
}
echo ""
echo "Running built-in smoke test..."
python src/pyhdwallet.py test || {
echo "ERROR: Smoke test failed!"
exit 1
}
echo ""
echo "=========================================="
echo "✓ Installation complete and verified!"
echo "=========================================="
echo ""
echo "Virtual environment: $VENV_DIR"
echo "Python version: $(python --version)"
echo ""
echo "To activate this environment:"
echo " source $VENV_DIR/bin/activate"
echo ""
echo "Generate a wallet with:"
echo " python src/pyhdwallet.py gen --off-screen --file"
echo ""

File diff suppressed because it is too large Load Diff

7
pytest.ini Normal file
View File

@@ -0,0 +1,7 @@
[pytest]
filterwarnings =
ignore::DeprecationWarning:pgpy.constants
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

2
requirements-dev.in Normal file
View File

@@ -0,0 +1,2 @@
-r requirements.txt # Include production deps
pytest>=9.0.0

81
requirements-dev.txt Normal file
View File

@@ -0,0 +1,81 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile requirements-dev.in
#
base58==2.1.1
# via -r requirements.txt
bip-utils==2.10.0
# via -r requirements.txt
cbor2==5.8.0
# via
# -r requirements.txt
# bip-utils
cffi==2.0.0
# via
# -r requirements.txt
# cryptography
# pynacl
coincurve==21.0.0
# via
# -r requirements.txt
# bip-utils
crcmod==1.7
# via
# -r requirements.txt
# bip-utils
cryptography==46.0.3
# via
# -r requirements.txt
# pgpy
ecdsa==0.19.1
# via
# -r requirements.txt
# bip-utils
ed25519-blake2b==1.4.1
# via
# -r requirements.txt
# bip-utils
iniconfig==2.3.0
# via pytest
packaging==25.0
# via pytest
pgpy==0.6.0
# via -r requirements.txt
pluggy==1.6.0
# via pytest
py-sr25519-bindings==0.2.3
# via
# -r requirements.txt
# bip-utils
pyasn1==0.6.1
# via
# -r requirements.txt
# pgpy
pycparser==2.23
# via
# -r requirements.txt
# cffi
pycryptodome==3.23.0
# via
# -r requirements.txt
# bip-utils
pycryptodomex==3.23.0
# via
# -r requirements.txt
# pyzipper
pygments==2.19.2
# via pytest
pynacl==1.6.2
# via
# -r requirements.txt
# bip-utils
pytest==9.0.2
# via -r requirements-dev.in
pyzipper==0.3.6
# via -r requirements.txt
six==1.17.0
# via
# -r requirements.txt
# ecdsa

View File

@@ -1,2 +1,8 @@
PGPy
bip-utils
# Core crypto libraries
bip-utils>=2.9.0
PGPy>=0.6.0
pynacl>=1.5.0
base58>=2.1.1
# ZIP encryption
pyzipper>=0.3.6

View File

@@ -1,9 +1,11 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile
# pip-compile requirements.in
#
base58==2.1.1
# via -r requirements.in
bip-utils==2.10.0
# via -r requirements.in
cbor2==5.8.0
@@ -32,7 +34,13 @@ pycparser==2.23
# via cffi
pycryptodome==3.23.0
# via bip-utils
pycryptodomex==3.23.0
# via pyzipper
pynacl==1.6.2
# via bip-utils
# via
# -r requirements.in
# bip-utils
pyzipper==0.3.6
# via -r requirements.in
six==1.17.0
# via ecdsa

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
{
"master_fingerprint": "DD1449B7",
"passphrase_used": false,
"passphrase_hint": "",
"dice_rolls_used": false,
"solana_profile": "phantom_bip44change",
"addresses": {
"ethereum": [
{
"index": 0,
"path": "m/44'/60'/0'/0/0",
"address": "0x9d3e3540f4C507ca992035607326798130051e03"
}
]
}
}

104
tests/bootstrap_vectors.py Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Bootstrap script to generate tests/vectors.json.
Run this ONCE (online allowed if needed, though this logic is offline)
to freeze the expected outputs of the current pyhdwallet implementation.
"""
import sys
import json
import os
from pathlib import Path
# Add src to path so we can import pyhdwallet
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
import pyhdwallet
from bip_utils import Bip39SeedGenerator
def main():
print("Bootstrapping test vectors...")
vectors = {}
# 1. PGP Fingerprint Vector
recipient_path = Path(__file__).parent / "data" / "recipient.asc"
if not recipient_path.exists():
print(f"Error: {recipient_path} not found. Please create it first.")
sys.exit(1)
with open(recipient_path, "r", encoding="utf-8") as f:
armored = f.read()
# Calculate expected fingerprint using current code logic
expected_fpr = pyhdwallet.pgp_fingerprint(armored)
vectors["pgp"] = {
"recipient_file": "recipient.asc",
"expected_fingerprint": expected_fpr
}
print(f"PGP: Pinned fingerprint {expected_fpr}")
# 2. BIP39 & Derivation Vectors
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
vectors["bip39"] = []
# Case A: No Passphrase
seed_bytes = Bip39SeedGenerator(mnemonic).Generate("")
seed_hex = seed_bytes.hex()
# Generate addresses for all supported chains/profiles
chains = ["ethereum", "bitcoin", "solana"]
# Fixed profile names to use underscores instead of hyphens
sol_profiles = ["phantom_bip44change", "phantom_bip44", "solana_bip39_first32"]
derived_data = {}
# Run derivation for each sol profile logic
for profile in sol_profiles:
res = pyhdwallet.derive_all(
seed_bytes,
chains,
count=5,
sol_profile=profile,
export_private=False
)
derived_data[profile] = res["addresses"]
vectors["bip39"].append({
"mnemonic": mnemonic,
"passphrase": "",
"expected_seed_hex": seed_hex,
"derived_addresses": derived_data
})
# Case B: With Passphrase (Regression test)
passphrase = "TREZOR"
seed_bytes_p = Bip39SeedGenerator(mnemonic).Generate(passphrase)
seed_hex_p = seed_bytes_p.hex()
res_p = pyhdwallet.derive_all(
seed_bytes_p,
chains,
count=1,
sol_profile="phantom_bip44change",
export_private=False
)
vectors["bip39"].append({
"mnemonic": mnemonic,
"passphrase": passphrase,
"expected_seed_hex": seed_hex_p,
"derived_addresses": {"phantom_bip44change": res_p["addresses"]}
})
print("Derivation: Generated vectors for empty and non-empty passphrases.")
# 3. Save
out_path = Path(__file__).parent / "vectors.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(vectors, f, indent=2)
print(f"Success! Vectors written to {out_path}")
if __name__ == "__main__":
main()

52
tests/data/recipient.asc Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGE3cD4BEADRf2MGzsF6lV/vtEZLSz+kMs7LWHfxycrwhK1rlqAEblMJdDrs
odQ/Ep8y03TQ6iQfOaZHfthofvvEfYpNMEPRorvGDoW400haAooAxS4ieASy+YtB
lUDQIF3HIk3cbTFDjsXsJhKDNhHxPmCnngwXz6l8xdz6JJr7QjUuprfoWztPwzQ2
5M77jYi3M5K7e35b5dbvWq7hCsVoeYyj9BO+OVC48F/qfb91Ht9EgbBVpJK6Y9tN
OIceRPoYw08WUCKM6hNRb8Nf0N+PY9edboAAEUhjkaCj64cGAzGhFBC4VCq3Dtc0
GGQwlIGrooRhsghKlNKkCd2en3R+icMsbPdEdd3LpNF+iHAM/btZ30Wdw+woQQQR
aQFf446Dshmu4d4pKTsHypw2aTmoqKzZ0ow/fKTq1MaASTezU3eGAQa7fr3n1xrY
plWJISOcguJx/NO+XlW8B4AMW11xeeWoCvpxeGWuGk1SFa8RI7ExD4WKN+MCGkSE
UZT0fQfSkQjr3fIw2q2WVjgkFN4Vu20e9ea4ZcSaeZvF6auj26Ypfa6JG+usOxDR
fjNQ5II/FKrlZl/Ry9gDui6uvWbLQjEinA3hfDCzk33uIWjg3PU73bdh87vDhqFa
NkF49Go6+J7AaeOAp6FgMwxLAh5HrvxNSsDhcApv82XhEo3EGX+M5vpnqQARAQAB
tB1LY2MgTGVvIEMgPGtjY2xlb2NAZ21haWwuY29tPokCWAQTAQgAQhYhBKJ7lvKx
abVJEBPS2okrgiwUqaoYBQJhN3A+AhsDBQkSzAMABQsJCAcCAyICAQYVCgkICwIE
FgIDAQIeBwIXgAAKCRCJK4IsFKmqGK4ND/9ID2dnOBrobB8Y3hzwwVT1yvztODjE
n3jA3F78cZhDaBJvcNFcmDHFyZSLsR4gtkTREuRtjF4bb/bN0lQsiwgTSSJ04bZm
0Its04EQuZQw9hl/XxQEka+7GOi1jcUq/9n7A2DS1RkQ9kElJlR4MyvVUwohMmNA
y1vkKMQrME2PGeqQ0mp3tlIpBBohEQzwB0d+dxjVyjpfiDVAMjD5OBf/tI1sND/R
7fIDYTxOh8pDRWH4kJDr8LnDehGXPKab9IjK15DXzTS9FQ27QI654pZDlhZmj0GG
fZvKb8EhjB0FeKiP+RnaTFI+fLd8iIi7aoTSiPqst1F7kDNi9xaqq08NA0tZwvRt
GHKcdmZ+GF9Nui7j6X30oGPpUAccH033px4iVQB78qS5ZGAyIerW54gumGLxqQh7
5ozIRxjjfMdD+R9ANa5hwQnv1nXlS0wOgzok0b86LV7Lid7NZ/1RO3lIt2CDFbZS
OsmMy5XCeup4KT+lJyGqOCVznIzUDUA2ZQoX2cBxwPt60PFIxnCGnf8DAZAjY/WI
6w+c4r+tnkWezDZ6T8P6+Vg+EgLg3ZJ1ZY4S7wFGCIQYxdZfBeIy1RmsHdA/jzOd
zMsj0u+BJwbrvj8It+10n0mh/VaIhsK0/Mowf78/NvRziMZ3C9Y5g4YZxwK1f2x8
B2tKo0QmtpTz9bkCDQRhN3A+ARAAtQRA2DXVIQBodQveLdng83EjsWEKBRZLeqAm
KXjSe8LxsgjL36cttXvlA0UuD/dTa83KYrROhNzuSlGGQ1eDKl+VAgBI3Qbw3fTs
ahK4vHX6cc3V4esPaUZU7P/xBzmhro/hHrF5vLMv7Sa4iNTJgdgez6uB9hccL0BD
Ro8nO8qzZQJJnLFWy9Lk5jsiLxij7vvRFCTsUpHCeNIJ+/EHvn5oXXb775WYzTs0
zRT2cZEJ1z8Ji+TGQUoukg2KBBwPMUI7ilscf5IbIJmXuWkyHBJa/PqrKuYuM8DC
Iur+CdlInw4yet/i3y0t8wwrhaigz1kHURSExO1wasEmqlkCDcQsYfjF0Edyf22H
CY/9vdX51Hf3p9KOi8lWclKYzWHMbX3PNEH5L3WRV6MxEFt8zCaue/3htAkn5Bts
+AJ4a6bdBrO7zj8ZR9Xx/PsDRcH+vJL/mbiYsvyO2UJAoeS4Vf6/pG040sEfSNUB
+FtbXgqqA9tjORKLekxKA4ZG1FIhLjL4JRJ8FrqMDmh2Cj7f0t4dI3Ubm8UymhHH
t2771PE1vov0gKdtpyqWx/5MErdggiglI2lebBeefMB+NQSJJM7h3RrGpNTezmnx
QBcGIgTrHVrXEaXUSV2/QdJwJicuTxROAf4wLhEf1OxPEzpgqvPjh7aAL+9+cxom
scY+S4MAEQEAAYkCPAQYAQgAJhYhBKJ7lvKxabVJEBPS2okrgiwUqaoYBQJhN3A+
AhsMBQkSzAMAAAoJEIkrgiwUqaoYaVcP/i0PF32Ri5WSy0CMN4nLgsEt1kOwpfQj
8VaJl6YIxyHluCa2yXpc3GP4XuvP/GKO6ODRhlBWOQn532jDpFdkJnef/z0L9hct
55/qxXc+7X65cw6T6ZfEJVCx4jt9mhm5g+1XOnVG+E/7DL97+AnKUYVepi2z6jfv
Ql6HosEjXMRWwF8wF+5fwieqMA8yldkdzHcSb3fiWYycQUGPyXdJGht0mXrmfMh+
4EUpHpu75ott+vj/yVFvDKWu1bhznA7Ma9HASV1QWmIvywNYUGcDcXEPVoYjTmBK
Dz6S5sWWeTjJo+GPOfgF8BLK3xkZy3vULsAUq5xu9ryyvY2MdZeMN3RrJ7aRmFhh
HZU+MK17LNWF3IqqJPJdJvV94mKouqHTyV7BGMs2y1eTC7mZBvmcZpm2ECqmg8RQ
WXFospGZmcXYQVsgNIGy3nonzgF2EaUEqYKfl1P4zQvMOio79svp/0TuJVi9I/AW
p+HbSJ6r75vxpBnk18N00wy5h3dhuc3MfiEnlXKbbr8Zstb9tJLy8Bm0KihDUyQX
qFGnHPzRO36fh4eUkTlShzMrx9+bzkVTsjzK3dVviPnnNykfR63DJSuEOxT5kVPG
geKpo+1BeSAXduK2SpEVvNBYlZFNki6sbuCvcGd49FTq0ZKFvUWtNTTvGSugeOSb
gohx1u+yjx1L
=vol8
-----END PGP PUBLIC KEY BLOCK-----

91
tests/generate_bip85_vectors.py Executable file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Generate BIP85 test vectors for vectors.json
Run this from the repo root:
python3 tests/generate_bip85_vectors.py
"""
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pyhdwallet
from bip_utils import Bip39SeedGenerator
import json
# Test cases to generate
test_cases = [
{
"description": "BIP85 test case 1: 12-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 12,
"index": 0,
},
{
"description": "BIP85 test case 2: 18-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 18,
"index": 0,
},
{
"description": "BIP85 test case 3: 24-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 24,
"index": 0,
},
{
"description": "BIP85 test case 4: 12-word child, WITH passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "TREZOR",
"child_words": 12,
"index": 0,
},
]
print("Generating BIP85 test vectors...\n")
print("=" * 80)
vectors = []
for case in test_cases:
print(f"\n{case['description']}")
print("-" * 80)
# Generate master seed
master_seed = Bip39SeedGenerator(case["master_mnemonic"]).Generate(case["master_passphrase"])
# Derive child using BIP85
path, child_mnemonic, entropy64, truncated_entropy = pyhdwallet.bip85_derive_child_mnemonic(
master_seed,
case["child_words"],
case["index"]
)
vector = {
"description": case["description"],
"master_mnemonic": case["master_mnemonic"],
"master_passphrase": case["master_passphrase"],
"child_words": case["child_words"],
"index": case["index"],
"bip85_path": path,
"expected_entropy64_hex": entropy64.hex(),
"expected_entropy_truncated_hex": truncated_entropy.hex(),
"expected_child_mnemonic": child_mnemonic,
}
vectors.append(vector)
print(f"Path: {path}")
print(f"Entropy64: {entropy64.hex()}")
print(f"Truncated: {truncated_entropy.hex()}")
print(f"Child mnemonic: {child_mnemonic}")
print("\n" + "=" * 80)
print("\nJSON output for vectors.json:\n")
print(json.dumps(vectors, indent=2))

221
tests/test_vectors.py Normal file
View File

@@ -0,0 +1,221 @@
import sys
import os
import json
import pytest
import subprocess
from pathlib import Path
# Add src to path at the very top
TEST_DIR = Path(__file__).parent
SRC_DIR = TEST_DIR.parent / "src"
sys.path.insert(0, str(SRC_DIR))
import pyhdwallet
from bip_utils import Bip39SeedGenerator
DATA_DIR = TEST_DIR / "data"
VECTORS_FILE = TEST_DIR / "vectors.json"
@pytest.fixture
def vectors():
if not VECTORS_FILE.exists():
pytest.fail("tests/vectors.json missing. Run tests/bootstrap_vectors.py first.")
with open(VECTORS_FILE, "r") as f:
return json.load(f)
@pytest.fixture
def recipient_key_content():
path = DATA_DIR / "recipient.asc"
if not path.exists():
pytest.fail("tests/data/recipient.asc missing.")
with open(path, "r", encoding="utf-8") as f:
return f.read()
def test_pgp_fingerprint_calculation(vectors, recipient_key_content):
"""
Verifies that pgp_fingerprint computes the expected fingerprint
for the stored recipient key.
"""
expected = vectors["pgp"]["expected_fingerprint"]
actual = pyhdwallet.pgp_fingerprint(recipient_key_content)
assert actual == expected, "Fingerprint calculation logic has changed!"
def test_pgp_require_fingerprint_match(vectors):
"""
Verifies the safety check require_fingerprint_match enforces exact matches.
"""
expected = vectors["pgp"]["expected_fingerprint"]
# Should not raise
pyhdwallet.require_fingerprint_match(expected, expected, "test")
# Should raise on mismatch
wrong = expected.replace("A", "B") if "A" in expected else expected.replace("0", "1")
with pytest.raises(ValueError, match="test: PGP fingerprint mismatch"):
pyhdwallet.require_fingerprint_match(wrong, expected, "test")
def test_bip39_seed_derivation(vectors):
"""
Verifies that mnemonics convert to seeds exactly as they did at bootstrap time.
"""
for case in vectors["bip39"]:
mnemonic = case["mnemonic"]
passphrase = case["passphrase"]
expected_hex = case["expected_seed_hex"]
# Verify internal Bip39SeedGenerator usage matches expected hex
actual_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
assert actual_bytes.hex() == expected_hex
def test_address_derivation_integrity(vectors):
"""
Verifies derive_all produces the exact same addresses for supported chains.
"""
for case in vectors["bip39"]:
seed_hex = case["expected_seed_hex"]
seed_bytes = bytes.fromhex(seed_hex)
for profile, expected_addresses in case["derived_addresses"].items():
# Infer count from ethereum addresses in the expected data
# (Ethereum always generates `count` addresses, unlike Solana which depends on profile)
count = len(expected_addresses.get("ethereum", []))
result = pyhdwallet.derive_all(
seed_bytes,
chains=["ethereum", "bitcoin", "solana"],
count=count,
sol_profile=profile,
export_private=False
)
assert result["addresses"] == expected_addresses, f"Mismatch for profile {profile}"
def test_bip85_derivation(vectors):
"""
Verifies BIP85 child mnemonic derivation matches expected test vectors.
Tests:
- Path construction
- Entropy64 derivation (HMAC-SHA512)
- Entropy truncation
- Child mnemonic generation
"""
from bip_utils import Bip39SeedGenerator
for case in vectors["bip85"]:
master_mnemonic = case["master_mnemonic"]
master_passphrase = case["master_passphrase"]
child_words = case["child_words"]
index = case["index"]
# Generate master seed
master_seed = Bip39SeedGenerator(master_mnemonic).Generate(master_passphrase)
# Derive child using BIP85
path, child_mnemonic, entropy64, truncated_entropy = pyhdwallet.bip85_derive_child_mnemonic(
master_seed,
child_words,
index
)
# Assert path
assert path == case["bip85_path"], f"Path mismatch: {path} != {case['bip85_path']}"
# Assert entropy64 (full 64-byte HMAC output)
assert entropy64.hex() == case["expected_entropy64_hex"], \
f"Entropy64 mismatch for {case['description']}"
# Assert truncated entropy
assert truncated_entropy.hex() == case["expected_entropy_truncated_hex"], \
f"Truncated entropy mismatch for {case['description']}"
# Assert child mnemonic
assert child_mnemonic == case["expected_child_mnemonic"], \
f"Child mnemonic mismatch for {case['description']}"
def test_cli_gen_child_smoke(tmp_path, vectors):
"""
CLI smoke test for gen-child command.
Verifies:
- Command runs without error
- ZIP file is created
- Off-screen mode works
- File output works
"""
vector = vectors["bip85"][0] # Use first BIP85 test case
master_mnemonic = vector["master_mnemonic"]
cmd = [
sys.executable, "src/pyhdwallet.py", "gen-child",
"--mnemonic-stdin",
"--index", "0",
"--words", "12",
"--off-screen",
"--file",
"--zip-password-mode", "auto",
"--wallet-location", str(tmp_path)
]
result = subprocess.run(
cmd,
input=master_mnemonic.encode("utf-8"),
capture_output=True
)
assert result.returncode == 0, f"CLI gen-child failed: {result.stderr.decode()}"
# Check ZIP file created
zips = list(tmp_path.glob("*.zip"))
assert len(zips) == 1, f"Expected exactly one zip file, found {len(zips)}"
# Check filename pattern
zip_name = zips[0].name
assert zip_name.startswith("bip85_child_"), f"Unexpected zip name: {zip_name}"
def test_cli_recover_smoke(tmp_path, vectors):
"""
Runs the CLI in a subprocess to verify end-to-end wiring
without network (recover mode).
"""
vector = vectors["bip39"][0]
mnemonic = vector["mnemonic"]
expected_fp = vectors["pgp"]["expected_fingerprint"]
recipient_file = DATA_DIR / "recipient.asc"
# 1. Successful Recovery
cmd = [
sys.executable, "src/pyhdwallet.py", "recover",
"--mnemonic-stdin",
"--pgp-pubkey-file", str(recipient_file),
"--expected-fingerprint", expected_fp,
"--file",
"--wallet-location", str(tmp_path),
"--off-screen",
"--zip-password-mode", "auto"
]
result = subprocess.run(
cmd,
input=mnemonic.encode("utf-8"),
capture_output=True
)
assert result.returncode == 0, f"CLI failed: {result.stderr.decode()}"
zips = list(tmp_path.glob("*.zip"))
assert len(zips) == 1, "Expected exactly one zip file output"
# 2. Failed Recovery (Wrong Fingerprint)
wrong_fp = expected_fp.replace("A", "B")
cmd_fail = list(cmd)
cmd_fail[cmd_fail.index(expected_fp)] = wrong_fp
result_fail = subprocess.run(
cmd_fail,
input=mnemonic.encode("utf-8"),
capture_output=True
)
assert result_fail.returncode != 0
assert b"fingerprint mismatch" in result_fail.stderr or b"fingerprint mismatch" in result_fail.stdout

528
tests/vectors.json Normal file
View File

@@ -0,0 +1,528 @@
{
"pgp": {
"recipient_file": "recipient.asc",
"expected_fingerprint": "A27B96F2B169B5491013D2DA892B822C14A9AA18"
},
"bip39": [
{
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"passphrase": "",
"expected_seed_hex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
"derived_addresses": {
"phantom_bip44change": {
"ethereum": [
{
"index": 0,
"path": "m/44'/60'/0'/0/0",
"address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
},
{
"index": 1,
"path": "m/44'/60'/0'/0/1",
"address": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
},
{
"index": 2,
"path": "m/44'/60'/0'/0/2",
"address": "0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A"
},
{
"index": 3,
"path": "m/44'/60'/0'/0/3",
"address": "0xF3f50213C1d2e255e4B2bAD430F8A38EEF8D718E"
},
{
"index": 4,
"path": "m/44'/60'/0'/0/4",
"address": "0x51cA8ff9f1C0a99f88E86B8112eA3237F55374cA"
}
],
"solana": [
{
"index": 0,
"path": "m/44'/501'/0'/0'",
"address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
},
{
"index": 1,
"path": "m/44'/501'/1'/0'",
"address": "Hh8QwFUA6MtVu1qAoq12ucvFHNwCcVTV7hpWjeY1Hztb"
},
{
"index": 2,
"path": "m/44'/501'/2'/0'",
"address": "7WktogJEd2wQ9eH2oWusmcoFTgeYi6rS632UviTBJ2jm"
},
{
"index": 3,
"path": "m/44'/501'/3'/0'",
"address": "3YqEpfo3c818GhvbQ1UmVY1nJxw16vtu4JB9peJXT94k"
},
{
"index": 4,
"path": "m/44'/501'/4'/0'",
"address": "6nod592sTfEWD3VSVPdQndLMVBCNmMc6ngt7MyGBK21j"
}
],
"bitcoin": [
{
"index": 0,
"path": "m/84'/0'/0'/0/0",
"address_type": "native_segwit",
"address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"
},
{
"index": 0,
"path": "m/49'/0'/0'/0/0",
"address_type": "segwit",
"address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"
},
{
"index": 0,
"path": "m/44'/0'/0'/0/0",
"address_type": "legacy",
"address": "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA"
},
{
"index": 1,
"path": "m/84'/0'/0'/0/1",
"address_type": "native_segwit",
"address": "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g"
},
{
"index": 1,
"path": "m/49'/0'/0'/0/1",
"address_type": "segwit",
"address": "3LtMnn87fqUeHBUG414p9CWwnoV6E2pNKS"
},
{
"index": 1,
"path": "m/44'/0'/0'/0/1",
"address_type": "legacy",
"address": "1Ak8PffB2meyfYnbXZR9EGfLfFZVpzJvQP"
},
{
"index": 2,
"path": "m/84'/0'/0'/0/2",
"address_type": "native_segwit",
"address": "bc1qp59yckz4ae5c4efgw2s5wfyvrz0ala7rgvuz8z"
},
{
"index": 2,
"path": "m/49'/0'/0'/0/2",
"address_type": "segwit",
"address": "3B4cvWGR8X6Xs8nvTxVUoMJV77E4f7oaia"
},
{
"index": 2,
"path": "m/44'/0'/0'/0/2",
"address_type": "legacy",
"address": "1MNF5RSaabFwcbtJirJwKnDytsXXEsVsNb"
},
{
"index": 3,
"path": "m/84'/0'/0'/0/3",
"address_type": "native_segwit",
"address": "bc1qgl5vlg0zdl7yvprgxj9fevsc6q6x5dmcyk3cn3"
},
{
"index": 3,
"path": "m/49'/0'/0'/0/3",
"address_type": "segwit",
"address": "38CahkVftQneLonbWtfWxiiaT2fdnzsEAN"
},
{
"index": 3,
"path": "m/44'/0'/0'/0/3",
"address_type": "legacy",
"address": "1MVGa13XFvvpKGZdX389iU8b3qwtmAyrsJ"
},
{
"index": 4,
"path": "m/84'/0'/0'/0/4",
"address_type": "native_segwit",
"address": "bc1qm97vqzgj934vnaq9s53ynkyf9dgr05rargr04n"
},
{
"index": 4,
"path": "m/49'/0'/0'/0/4",
"address_type": "segwit",
"address": "37mbeJptxfQC6SNNLJ9a8efCY4BwBh5Kak"
},
{
"index": 4,
"path": "m/44'/0'/0'/0/4",
"address_type": "legacy",
"address": "1Gka4JdwhLxRwXaC6oLNH4YuEogeeSwqW7"
}
]
},
"phantom_bip44": {
"ethereum": [
{
"index": 0,
"path": "m/44'/60'/0'/0/0",
"address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
},
{
"index": 1,
"path": "m/44'/60'/0'/0/1",
"address": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
},
{
"index": 2,
"path": "m/44'/60'/0'/0/2",
"address": "0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A"
},
{
"index": 3,
"path": "m/44'/60'/0'/0/3",
"address": "0xF3f50213C1d2e255e4B2bAD430F8A38EEF8D718E"
},
{
"index": 4,
"path": "m/44'/60'/0'/0/4",
"address": "0x51cA8ff9f1C0a99f88E86B8112eA3237F55374cA"
}
],
"solana": [
{
"index": 0,
"path": "m/44'/501'/0'",
"address": "GjJyeC1r2RgkuoCWMyPYkCWSGSGLcz266EaAkLA27AhL"
},
{
"index": 1,
"path": "m/44'/501'/1'",
"address": "ANf3TEKFL6jPWjzkndo4CbnNdUNkBk4KHPggJs2nu8Xi"
},
{
"index": 2,
"path": "m/44'/501'/2'",
"address": "Ag74i82rUZBTgMGLacCA1ZLnotvAca8CLscXcrG6Nwem"
},
{
"index": 3,
"path": "m/44'/501'/3'",
"address": "weCFpgyyyrzum6nA8XdmJXjDGDTXmG5P2DdgHv59hgQ"
},
{
"index": 4,
"path": "m/44'/501'/4'",
"address": "4w6V162fV7HJQNma7vZvxjunqmkie8hM2x1DqaFQqxqL"
}
],
"bitcoin": [
{
"index": 0,
"path": "m/84'/0'/0'/0/0",
"address_type": "native_segwit",
"address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"
},
{
"index": 0,
"path": "m/49'/0'/0'/0/0",
"address_type": "segwit",
"address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"
},
{
"index": 0,
"path": "m/44'/0'/0'/0/0",
"address_type": "legacy",
"address": "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA"
},
{
"index": 1,
"path": "m/84'/0'/0'/0/1",
"address_type": "native_segwit",
"address": "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g"
},
{
"index": 1,
"path": "m/49'/0'/0'/0/1",
"address_type": "segwit",
"address": "3LtMnn87fqUeHBUG414p9CWwnoV6E2pNKS"
},
{
"index": 1,
"path": "m/44'/0'/0'/0/1",
"address_type": "legacy",
"address": "1Ak8PffB2meyfYnbXZR9EGfLfFZVpzJvQP"
},
{
"index": 2,
"path": "m/84'/0'/0'/0/2",
"address_type": "native_segwit",
"address": "bc1qp59yckz4ae5c4efgw2s5wfyvrz0ala7rgvuz8z"
},
{
"index": 2,
"path": "m/49'/0'/0'/0/2",
"address_type": "segwit",
"address": "3B4cvWGR8X6Xs8nvTxVUoMJV77E4f7oaia"
},
{
"index": 2,
"path": "m/44'/0'/0'/0/2",
"address_type": "legacy",
"address": "1MNF5RSaabFwcbtJirJwKnDytsXXEsVsNb"
},
{
"index": 3,
"path": "m/84'/0'/0'/0/3",
"address_type": "native_segwit",
"address": "bc1qgl5vlg0zdl7yvprgxj9fevsc6q6x5dmcyk3cn3"
},
{
"index": 3,
"path": "m/49'/0'/0'/0/3",
"address_type": "segwit",
"address": "38CahkVftQneLonbWtfWxiiaT2fdnzsEAN"
},
{
"index": 3,
"path": "m/44'/0'/0'/0/3",
"address_type": "legacy",
"address": "1MVGa13XFvvpKGZdX389iU8b3qwtmAyrsJ"
},
{
"index": 4,
"path": "m/84'/0'/0'/0/4",
"address_type": "native_segwit",
"address": "bc1qm97vqzgj934vnaq9s53ynkyf9dgr05rargr04n"
},
{
"index": 4,
"path": "m/49'/0'/0'/0/4",
"address_type": "segwit",
"address": "37mbeJptxfQC6SNNLJ9a8efCY4BwBh5Kak"
},
{
"index": 4,
"path": "m/44'/0'/0'/0/4",
"address_type": "legacy",
"address": "1Gka4JdwhLxRwXaC6oLNH4YuEogeeSwqW7"
}
]
},
"solana_bip39_first32": {
"ethereum": [
{
"index": 0,
"path": "m/44'/60'/0'/0/0",
"address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
},
{
"index": 1,
"path": "m/44'/60'/0'/0/1",
"address": "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
},
{
"index": 2,
"path": "m/44'/60'/0'/0/2",
"address": "0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A"
},
{
"index": 3,
"path": "m/44'/60'/0'/0/3",
"address": "0xF3f50213C1d2e255e4B2bAD430F8A38EEF8D718E"
},
{
"index": 4,
"path": "m/44'/60'/0'/0/4",
"address": "0x51cA8ff9f1C0a99f88E86B8112eA3237F55374cA"
}
],
"solana": [
{
"index": 0,
"path": "BIP39 seed[0:32]",
"address": "EHqmfkN89RJ7Y33CXM6uCzhVeuywHoJXZZLszBHHZy7o"
}
],
"bitcoin": [
{
"index": 0,
"path": "m/84'/0'/0'/0/0",
"address_type": "native_segwit",
"address": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"
},
{
"index": 0,
"path": "m/49'/0'/0'/0/0",
"address_type": "segwit",
"address": "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf"
},
{
"index": 0,
"path": "m/44'/0'/0'/0/0",
"address_type": "legacy",
"address": "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA"
},
{
"index": 1,
"path": "m/84'/0'/0'/0/1",
"address_type": "native_segwit",
"address": "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g"
},
{
"index": 1,
"path": "m/49'/0'/0'/0/1",
"address_type": "segwit",
"address": "3LtMnn87fqUeHBUG414p9CWwnoV6E2pNKS"
},
{
"index": 1,
"path": "m/44'/0'/0'/0/1",
"address_type": "legacy",
"address": "1Ak8PffB2meyfYnbXZR9EGfLfFZVpzJvQP"
},
{
"index": 2,
"path": "m/84'/0'/0'/0/2",
"address_type": "native_segwit",
"address": "bc1qp59yckz4ae5c4efgw2s5wfyvrz0ala7rgvuz8z"
},
{
"index": 2,
"path": "m/49'/0'/0'/0/2",
"address_type": "segwit",
"address": "3B4cvWGR8X6Xs8nvTxVUoMJV77E4f7oaia"
},
{
"index": 2,
"path": "m/44'/0'/0'/0/2",
"address_type": "legacy",
"address": "1MNF5RSaabFwcbtJirJwKnDytsXXEsVsNb"
},
{
"index": 3,
"path": "m/84'/0'/0'/0/3",
"address_type": "native_segwit",
"address": "bc1qgl5vlg0zdl7yvprgxj9fevsc6q6x5dmcyk3cn3"
},
{
"index": 3,
"path": "m/49'/0'/0'/0/3",
"address_type": "segwit",
"address": "38CahkVftQneLonbWtfWxiiaT2fdnzsEAN"
},
{
"index": 3,
"path": "m/44'/0'/0'/0/3",
"address_type": "legacy",
"address": "1MVGa13XFvvpKGZdX389iU8b3qwtmAyrsJ"
},
{
"index": 4,
"path": "m/84'/0'/0'/0/4",
"address_type": "native_segwit",
"address": "bc1qm97vqzgj934vnaq9s53ynkyf9dgr05rargr04n"
},
{
"index": 4,
"path": "m/49'/0'/0'/0/4",
"address_type": "segwit",
"address": "37mbeJptxfQC6SNNLJ9a8efCY4BwBh5Kak"
},
{
"index": 4,
"path": "m/44'/0'/0'/0/4",
"address_type": "legacy",
"address": "1Gka4JdwhLxRwXaC6oLNH4YuEogeeSwqW7"
}
]
}
}
},
{
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"passphrase": "TREZOR",
"expected_seed_hex": "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
"derived_addresses": {
"phantom_bip44change": {
"ethereum": [
{
"index": 0,
"path": "m/44'/60'/0'/0/0",
"address": "0x9c32F71D4DB8Fb9e1A58B0a80dF79935e7256FA6"
}
],
"solana": [
{
"index": 0,
"path": "m/44'/501'/0'/0'",
"address": "7zSmbu6gKkb6HB7UDPtHYjwCWuBHU1D4TpNZFm4sndQe"
}
],
"bitcoin": [
{
"index": 0,
"path": "m/84'/0'/0'/0/0",
"address_type": "native_segwit",
"address": "bc1qv5rmq0kt9yz3pm36wvzct7p3x6mtgehjul0feu"
},
{
"index": 0,
"path": "m/49'/0'/0'/0/0",
"address_type": "segwit",
"address": "3Aho3kS7vgVWKTpRHjcqBoPXiCujiSuTaZ"
},
{
"index": 0,
"path": "m/44'/0'/0'/0/0",
"address_type": "legacy",
"address": "1PEha8dk5Me5J1rZWpgqSt5F4BroTBLS5y"
}
]
}
}
}
],
"bip85": [
{
"description": "BIP85 test case 1: 12-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 12,
"index": 0,
"bip85_path": "m/83696968'/39'/0'/12'/0'",
"expected_entropy64_hex": "ac98dac5d4f4ebad6056682ac95eb9ad9ba94fb68e96848264dad0b4357d002e41b3dd7a4c6f4ebc234be6938495840a73f59e9ba0e8e5c5208c94e6df2d7709",
"expected_entropy_truncated_hex": "ac98dac5d4f4ebad6056682ac95eb9ad",
"expected_child_mnemonic": "prosper short ramp prepare exchange stove life snack client enough purpose fold"
},
{
"description": "BIP85 test case 2: 18-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 18,
"index": 0,
"bip85_path": "m/83696968'/39'/0'/18'/0'",
"expected_entropy64_hex": "fc039f51d67ed7dfd01552f27de28887cf3e58655153e44b023d37578321f7083241970730e522d3f20b38a5296c5e51e57e0429546629704a09c6d1e2d10829",
"expected_entropy_truncated_hex": "fc039f51d67ed7dfd01552f27de28887cf3e58655153e44b",
"expected_child_mnemonic": "winter brother stamp provide uniform useful doctor prevent venue upper peasant auto view club next clerk tone fox"
},
{
"description": "BIP85 test case 3: 24-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 24,
"index": 0,
"bip85_path": "m/83696968'/39'/0'/24'/0'",
"expected_entropy64_hex": "d5a9cb46670566c4246b6e7af22e1dfc3668744ed831afea7ce2beea44e34e23e348e86091f24394f4be6253a7d5d24b91b1c4e0863b296e9e541e8018288897",
"expected_entropy_truncated_hex": "d5a9cb46670566c4246b6e7af22e1dfc3668744ed831afea7ce2beea44e34e23",
"expected_child_mnemonic": "stick exact spice sock filter ginger museum horse kit multiply manual wear grief demand derive alert quiz fault december lava picture immune decade jaguar"
},
{
"description": "BIP85 test case 4: 12-word child, WITH passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "TREZOR",
"child_words": 12,
"index": 0,
"bip85_path": "m/83696968'/39'/0'/12'/0'",
"expected_entropy64_hex": "2b1d7c4f311137fa95f6302e64cdb88584d52b51b57d0430ee68e148b82baa3f8c40316397eb404f4573bc0c8e5c4bc14e4aa5f0f472a9d3587f494f1f7b3684",
"expected_entropy_truncated_hex": "2b1d7c4f311137fa95f6302e64cdb885",
"expected_child_mnemonic": "climb typical because giraffe beach wool fit ship common chapter hotel arm"
}
]
}

18
vendor/linux-x86_64/SHA256SUMS vendored Normal file
View File

@@ -0,0 +1,18 @@
11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2 base58-2.1.1-py3-none-any.whl
33792674bda552a071a539b6590b2986aa8c08d0c9c30c2566d7cb323173310d bip_utils-2.10.0-py3-none-any.whl
518c118a5e00001854adb51f3164e647aa99b6a9877d2a733a28cb5c0a4d6857 cbor2-5.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4 coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
1ef255bb24cb5116c7e6c83cf284db2146f0158b1fbe8c63ad7b1df214d141ba crcmod-1.7-cp312-cp312-linux_aarch64.whl
549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91 cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl
30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3 ecdsa-0.19.1-py2.py3-none-any.whl
c2695b1eac2f6bda962450def9f92639532630e2a8ba694bc56db3f9aa9454da ed25519_blake2b-1.4.1-cp312-cp312-linux_aarch64.whl
eb3aa8bcaf699be242e9c5e152cc5da37bc1dceb697454cd1b596cdb10f54f3c pgpy-0.6.0-py3-none-any.whl
a3929c291408e67a1a11566f251b9f7d06c3fb3ae240caec44b9181de09e3fc9 py_sr25519_bindings-0.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 pyasn1-0.6.1-py3-none-any.whl
e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 pycparser-2.23-py3-none-any.whl
67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490 pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587 pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130 pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl
6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87 pyzipper-0.3.6-py2.py3-none-any.whl
4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 six-1.17.0-py2.py3-none-any.whl

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
vendor/macos-arm64/PGPy-0.6.0.tar.gz vendored Normal file

Binary file not shown.

15
vendor/macos-arm64/SHA256SUMS vendored Normal file
View File

@@ -0,0 +1,15 @@
11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2 base58-2.1.1-py3-none-any.whl
33792674bda552a071a539b6590b2986aa8c08d0c9c30c2566d7cb323173310d bip_utils-2.10.0-py3-none-any.whl
4b3f91fa699a5ce22470e973601c62dd9d55dc3ca20ee446516ac075fcab27c9 cbor2-5.8.0-cp312-cp312-macosx_11_0_arm64.whl
8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl
1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2 coincurve-21.0.0-cp312-cp312-macosx_11_0_arm64.whl
109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl
30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3 ecdsa-0.19.1-py2.py3-none-any.whl
4443adf871e224493c4ee4c06be205a10ea649a781132af883f6638fd7acc9d7 py_sr25519_bindings-0.2.3-cp312-cp312-macosx_11_0_arm64.whl
0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 pyasn1-0.6.1-py3-none-any.whl
e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 pycparser-2.23-py3-none-any.whl
187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27 pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl
06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6 pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl
c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465 pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl
6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87 pyzipper-0.3.6-py2.py3-none-any.whl
4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 six-1.17.0-py2.py3-none-any.whl

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
vendor/macos-arm64/crcmod-1.7.tar.gz vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.