Compare commits

..

24 Commits

Author SHA1 Message Date
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
175 changed files with 3603 additions and 1653 deletions

5
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.venv/
.venv312/
__pycache__/
*.pyc
.vscode/
@@ -9,6 +10,7 @@ logs/
*.log
coverage/
_toDelete/
_toDelete/
dist/
build/
*.egg-info/
@@ -22,3 +24,6 @@ build/
*.sqlite3
*.db
*.asc
.venv/
.wallet/
.potentialfix.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"]

169
Makefile Normal file
View File

@@ -0,0 +1,169 @@
# 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
# ---------- 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 "Vendoring (for offline/air-gapped use):"
@echo " make vendor-macos - Build macOS ARM64 wheels (native)"
@echo " make vendor-linux - Build Linux x86_64 wheels (Docker)"
@echo " make vendor-all - Build wheels for both platforms"
@echo " make verify-vendor - Test offline installation from vendor/"
@echo ""
@echo "Development workflow:"
@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"
# ---------- 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 \
'
# ---------- 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) vendor/ .venv-verify .venv-offline-test
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

212
README.md
View File

@@ -1,31 +1,203 @@
# 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.
---
## 🆕 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'"

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()

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"
}
]
}

43
vendor/PROVENANCE.md vendored Normal file
View File

@@ -0,0 +1,43 @@
# Dependency Provenance
All wheels generated on: 2026-01-07 17:06:51 UTC
Python version: 3.12
Build machine: Darwin arm64
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
base58==2.1.1
bip_utils==2.10.0
build==1.3.0
cbor2==5.8.0
cffi==2.0.0
click==8.3.1
coincurve==21.0.0
crcmod==1.7
cryptography==46.0.3
ecdsa==0.19.1
ed25519-blake2b==1.4.1
iniconfig==2.3.0
packaging==25.0
PGPy==0.6.0
pip-chill==1.0.3
pip-tools==7.5.2
pluggy==1.6.0
py-sr25519-bindings==0.2.3
pyasn1==0.6.1
pycparser==2.23
## Integrity Verification
Each platform directory contains SHA256SUMS for verification:
```bash
cd vendor/linux-x86_64
shasum -a 256 -c SHA256SUMS
```
Last updated: 2026-01-07
Built by: LC mac <leochan@hkjin.com>
Commit: 2807982

31
vendor/linux-aarch64/SHA256SUMS vendored Normal file
View File

@@ -0,0 +1,31 @@
11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2 base58-2.1.1-py3-none-any.whl
33792674bda552a071a539b6590b2986aa8c08d0c9c30c2566d7cb323173310d bip_utils-2.10.0-py3-none-any.whl
7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4 build-1.3.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
981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6 click-8.3.1-py3-none-any.whl
5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4 coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
6a6b11d6c42a450359f0d7f2312cb1fe69493ae8314dc3f6674b95cefed469a2 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
75c8d36691348abdd395b22f2c2ac5dd5c153c653550e6d18da5b0e433d7ce84 ed25519_blake2b-1.4.1-cp312-cp312-linux_aarch64.whl
f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 iniconfig-2.3.0-py3-none-any.whl
29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 packaging-25.0-py3-none-any.whl
8d6f2b1a217ccefd933c7ec8bf69927e90d4630093d0af2404fe4e33703dcf0f pgpy-0.6.0-py3-none-any.whl
9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd pip-25.3-py3-none-any.whl
452a38edbcdfc333301c438c26ba00a0762d2034fe26a235d8587134453ccdb1 pip_chill-1.0.3-py2.py3-none-any.whl
2fe16db727bbe5bf28765aeb581e792e61be51fc275545ef6725374ad720a1ce pip_tools-7.5.2-py3-none-any.whl
e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 pluggy-1.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
86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b pygments-2.19.2-py3-none-any.whl
26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130 pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl
9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 pyproject_hooks-1.2.0-py3-none-any.whl
711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b pytest-9.0.2-py3-none-any.whl
6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87 pyzipper-0.3.6-py2.py3-none-any.whl
062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 setuptools-80.9.0-py3-none-any.whl
4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 six-1.17.0-py2.py3-none-any.whl
708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248 wheel-0.45.1-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.

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -0,0 +1,31 @@
11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2 base58-2.1.1-py3-none-any.whl
33792674bda552a071a539b6590b2986aa8c08d0c9c30c2566d7cb323173310d bip_utils-2.10.0-py3-none-any.whl
7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4 build-1.3.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
981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6 click-8.3.1-py3-none-any.whl
5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4 coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
82d740f4e923b88981b6ca8b854dceb270f319c81153211ccbec6ee523c7ccee 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
96427209d130354f399ef20acc444c2ac76dd05b13cd270dea79909c39cdf6ec ed25519_blake2b-1.4.1-cp312-cp312-linux_aarch64.whl
f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 iniconfig-2.3.0-py3-none-any.whl
29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 packaging-25.0-py3-none-any.whl
52a584525a9032726a8e28fc02ced9cec896f54aa5c6f5552035000821525f2d pgpy-0.6.0-py3-none-any.whl
452a38edbcdfc333301c438c26ba00a0762d2034fe26a235d8587134453ccdb1 pip_chill-1.0.3-py2.py3-none-any.whl
2fe16db727bbe5bf28765aeb581e792e61be51fc275545ef6725374ad720a1ce pip_tools-7.5.2-py3-none-any.whl
9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd pip-25.3-py3-none-any.whl
e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 pluggy-1.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
86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b pygments-2.19.2-py3-none-any.whl
26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130 pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl
9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 pyproject_hooks-1.2.0-py3-none-any.whl
711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b pytest-9.0.2-py3-none-any.whl
6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87 pyzipper-0.3.6-py2.py3-none-any.whl
062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 setuptools-80.9.0-py3-none-any.whl
4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 six-1.17.0-py2.py3-none-any.whl
708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248 wheel-0.45.1-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.

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.

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More