„Krótko: w tej części portfela Bitcoin SV zamieniam PBKDF2 na Argon2id (odporność na ataki GPU), dodaję obsługę UTXO, checksum HMAC do keystore, bezpieczne czyszczenie mnemonika z pamięci i wysyłanie transakcji z walidacją adresu. To część 3 serii."
W poprzedniej części obiecałem że zajmiemy się bezpieczeństwem i wysyłaniem transakcji. Tym razem pracy było sporo, bo po drodze okazało się że "trochę większe" bezpieczeństwo. to tak naprawdę kilka całkiem konkretnych rzeczy do zmiany. I znowu skończyło się na tym, że siedziałem i dłubałem..
Co się zmieniło względem części 2?
Zanim wrzucę kod, powiem co i dlaczego. W poprzedniej wersji portfela używaliśmy PBKDF2 do wyprowadzania klucza z hasła. To całkiem przyzwoity algorytm, ale ma jedną słabość - jest podatny na ataki GPU. Karta graficzna może liczyć miliony prób na sekundę równolegle a atakujący z porządną kartą graficzną może dość sprawnie przeszukiwać słowniki haseł.
PBKDF2 vs Argon2id: dlaczego to ważne dla bezpieczeństwa portfela
Zamiana jest prosta: Argon2id jest aktualnie złotym standardem. Wymusza nie tylko koszt CPU, ale też koszt pamięci - każda próba odgadnięcia hasła wymaga zajęcia 64 MB RAM. GPU ma setki rdzeni, ale pamięci współdzielonej mu brakuje. Zamiast milionów prób na sekundę, spada do tysięcy. W sumie to na nasze zabawy z portfelem to nawet szkoda zachodu, no ale jak już zmieniłem to zmieniłem :)
Oprócz tego dodałem:
- checksum HMAC do keystore - jeśli ktoś zmodyfikuje plik wallet_keystore.json, program to wykryje zanim spróbuje odszyfrować
- bytearray zamiast str dla mnemonika - możemy go ręcznie wyzerować z pamięci zaraz po użyciu
- uprawnienia do pliku przez icacls - keystore dostępny tylko dla Twojego konta Windows
- UTXO w bazie - portfel pobiera i zapisuje niewydane UTXO, nie tylko salda
- wysyłanie transakcji
- walidację adresu przed wysłaniem - żeby nie stracić satoshi przez literówkę
crypto.py
import os
import base64
import hmac
import hashlib
import json
import subprocess
from argon2.low_level import hash_secret_raw, Type
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from config import KEYSTORE_FILE
ARGON2_TIME_COST = 3 # liczba iteracji
ARGON2_MEMORY_COST = 65536 # 64 MB pamięci
ARGON2_PARALLELISM = 4 # wątki
ARGON2_HASH_LEN = 32 # długość klucza
def derive_key_from_password(password: str, salt: bytes) -> bytes:
return hash_secret_raw(
secret=password.encode("utf-8"),
salt=salt,
time_cost=ARGON2_TIME_COST,
memory_cost=ARGON2_MEMORY_COST,
parallelism=ARGON2_PARALLELISM,
hash_len=ARGON2_HASH_LEN,
type=Type.ID, # Argon2id
)
def _compute_checksum(keystore_without_checksum: dict, password: str) -> str:
content = json.dumps(keystore_without_checksum, sort_keys=True).encode()
return hmac.new(password.encode(), content, hashlib.sha256).hexdigest()
def encrypt_mnemonic(mnemonic: str, password: str) -> dict:
mnemonic_bytes = bytearray(mnemonic.encode("utf-8"))
salt = os.urandom(32)
key = derive_key_from_password(password, salt)
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, bytes(mnemonic_bytes), None)
# czyścimy mnemonic z pamięci
for i in range(len(mnemonic_bytes)):
mnemonic_bytes[i] = 0
keystore = {
"kdf": "argon2id",
"kdfparams": {
"salt": base64.b64encode(salt).decode(),
"time_cost": ARGON2_TIME_COST,
"memory_cost": ARGON2_MEMORY_COST,
"parallelism": ARGON2_PARALLELISM,
"hash_len": ARGON2_HASH_LEN,
},
"cipher": "aes-256-gcm",
"cipherparams": {
"nonce": base64.b64encode(nonce).decode(),
},
"ciphertext": base64.b64encode(ciphertext).decode(),
}
keystore["checksum"] = _compute_checksum(keystore, password)
return keystore
def decrypt_mnemonic(keystore: dict, password: str) -> str:
stored_checksum = keystore.pop("checksum", None)
expected = _compute_checksum(keystore, password)
keystore["checksum"] = stored_checksum
if not hmac.compare_digest(stored_checksum or "", expected):
raise ValueError("Keystore został zmodyfikowany lub hasło jest błędne.")
salt = base64.b64decode(keystore["kdfparams"]["salt"])
nonce = base64.b64decode(keystore["cipherparams"]["nonce"])
ciphertext = base64.b64decode(keystore["ciphertext"])
key = derive_key_from_password(password, salt)
aesgcm = AESGCM(key)
mnemonic_bytes = bytearray(aesgcm.decrypt(nonce, ciphertext, None))
mnemonic = mnemonic_bytes.decode("utf-8")
for i in range(len(mnemonic_bytes)):
mnemonic_bytes[i] = 0
return mnemonic
def secure_keystore_permissions():
"""Ogranicza dostęp do pliku keystore tylko dla aktualnego użytkownika."""
username = os.environ.get("USERNAME")
try:
subprocess.run(["icacls", KEYSTORE_FILE, "/inheritance:r"],
check=True, capture_output=True)
subprocess.run(["icacls", KEYSTORE_FILE, "/grant:r", f"{username}:F"],
check=True, capture_output=True)
except Exception as e:
print(f"Uwaga: nie udało się ustalić uprawnień: {e}")
db.py - dodajemy obsługę UTXO
def get_address_index(address: str, db_path: str = DB_FILE) -> int | None:
"""Zwraca index dla podanego adresu."""
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("SELECT derivation_index FROM addresses WHERE address = ?", (address,))
row = cur.fetchone()
conn.close()
return row[0] if row else None
def save_utxos(utxos: list[dict], db_path: str = DB_FILE):
"""Zapisuje UTXO do bazy."""
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("DELETE FROM utxos WHERE spent = 0")
for utxo in utxos:
cur.execute("SELECT id FROM addresses WHERE address = ?", (utxo["address"],))
row = cur.fetchone()
if row is None:
continue
cur.execute("""
INSERT OR IGNORE INTO utxos (txid, vout, value_sats, address_index, spent)
VALUES (?, ?, ?, ?, 0)
""", (utxo["txid"], utxo["vout"], utxo["value"], row[0]))
conn.commit()
conn.close()
def get_all_utxos(db_path: str = DB_FILE) -> list:
"""Zwraca wszystkie niewydane UTXO z adresami."""
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("""
SELECT u.txid, u.vout, u.value_sats, a.address, a.path
FROM utxos u
JOIN addresses a ON u.address_index = a.id
WHERE u.spent = 0
ORDER BY u.value_sats DESC
""")
rows = cur.fetchall()
conn.close()
return rows
woc.py - pobieranie UTXO
def fetch_tx_hex(txid: str) -> str:
url = f"https://api.whatsonchain.com/v1/bsv/{NETWORK}/tx/{txid}/hex"
response = requests.get(url)
response.raise_for_status()
return response.text.strip()
def fetch_utxos_for_addresses(addresses: list[str]) -> list[dict]:
all_utxos = []
total = len(addresses)
print(Fore.YELLOW + "\n Pobieranie UTXO...")
for i, address in enumerate(addresses):
percent = int(((i + 1) / total) * 100)
bar_length = 30 # pasek postępu :)
filled = int(bar_length * (i + 1) / total)
bar = "█" * filled + "-" * (bar_length - filled)
print(Fore.GREEN + f"\r|{bar}| {percent}% [{i+1}/{total}]",
end="", flush=True)
try:
url = f"https://api.whatsonchain.com/v1/bsv/{NETWORK}/address/{address}/unspent/all"
response = requests.get(url)
response.raise_for_status()
data = response.json()
for utxo in data.get("result", []):
all_utxos.append({
"txid": utxo["tx_hash"],
"vout": utxo["tx_pos"],
"value": utxo["value"],
"address": address,
})
except Exception as e:
print(Fore.RED + f"\nBłąd pobierania UTXO dla {address}: {e}")
time.sleep(0.2)
print(Fore.GREEN + "\n Pobieranie zakończone\n")
return all_utxos
Pasek postępu to nie tylko kosmetyka - przy większej liczbie adresów widać. że coś się dzieje, a nie że program się zawiesił 😄ui.py - UTXO w tej samej tabeli co adresy
def show_addresses(db_path: str = "wallet.db"):
rows = get_all_addresses(db_path)
if not rows:
print("Brak adresów w bazie.")
return
addresses = [row[2] for row in rows]
print("\nPobieram salda z WhatsOnChain...")
try:
balances = fetch_balances_from_woc(addresses)
except Exception as e:
print(f"Błąd pobierania sald: {e}")
balances = {}
# pobieramy UTXO tylko dla adresów z saldem
active = [addr for addr in addresses if balances.get(addr, 0) > 0]
if active:
try:
utxos = fetch_utxos_for_addresses(active)
save_utxos(utxos, db_path)
except Exception as e:
print(f"Błąd zapisywania UTXO: {e}")
utxos_by_address = {}
for txid, vout, value_sats, address, path in get_all_utxos(db_path):
if address not in utxos_by_address:
utxos_by_address[address] = []
utxos_by_address[address].append(f"{txid[:16]}... ({value_sats} sat)")
print(f"\n{'#':<5 aldo="" dres="" style="color: #c9d1d9;" yp="">12} UTXO")
print("-" * 110)
total = 0
for idx, change, addr, path, label in rows:
ch_str = "change" if change else "receive"
sats = balances.get(addr, 0)
bsv = sats / 1e8
total += sats
label_str = f" [{label}]" if label else ""
balance_str = (
f"{Fore.GREEN}{bsv:>12.8f}{Style.RESET_ALL}"
if sats > 0 else f"{bsv:>12.8f}"
)
utxo_list = utxos_by_address.get(addr, [])
first_utxo = utxo_list[0] if utxo_list else "-"
print(f"{idx:<5 addr:="" balance_str="" ch_str:="" extra_utxo="" f="" first_utxo="" for="" in="" label_str="" ore.cyan="" print="" tyle.reset_all="" utxo_list="">66}{Fore.CYAN}{extra_utxo}{Style.RESET_ALL}")
print("-" * 110)
print(f"{'RAZEM':<54 e8:="" total="">12.8f} BSV")
54>5>5>
Adres z kilkoma UTXO (bo dostał kilka wpłat) wyświetla pierwsze w tej samej linii, kolejne pod spodem wyrównane do kolumny.hd.py - klucz prywatny
from bsv import PrivateKey
def get_private_key_for_index(mnemonic: str, derivation_index: int) -> PrivateKey:
keys = bip44_derive_xprvs_from_mnemonic(
mnemonic,
derivation_index,
derivation_index + 1,
path=BIP44_DERIVATION_PATH,
change=0,
network="testnet",
)
xprv = keys[0]
return PrivateKey(xprv.private_key().wif())
Mała uwaga - bip44_derive_xprvs_from_mnemonic zwraca obiekt Xprv, a nie PrivateKey. Biblioteka py-sdk rozróżnia te dwa typy i P2PKH().unlock() chce konkretnie PrivateKey. Stąd ta konwersja przez .wif().main.py - wysyłanie transakcji i walidacja adresu
def validate_address(address: str) -> bool:
try:
from bsv.base58 import base58check_decode
decoded = base58check_decode(address)
if len(decoded) != 21:
return False
prefix = decoded[0]
if NETWORK == "test":
return prefix == 0x6f # adresy m... lub n...
else:
return prefix == 0x00 # adresy 1...
except Exception:
return False
async def _broadcast(inputs_data, output_address, change_address, mnemonic):
tx = Transaction()
for item in inputs_data:
tx_hex = fetch_tx_hex(item["txid"])
private_key = get_private_key_for_index(mnemonic, item["derivation_index"])
tx.add_input(TransactionInput(
source_transaction=Transaction.from_hex(tx_hex),
source_output_index=item["vout"],
unlocking_script_template=P2PKH().unlock(private_key),
))
private_key = None # czyścimy zaraz po użyciu
tx.add_output(TransactionOutput(
locking_script=P2PKH().lock(output_address),
satoshis=inputs_data[0]["amount_to_send"],
))
tx.add_output(TransactionOutput(
locking_script=P2PKH().lock(change_address),
change=True, # biblioteka sama wyliczy resztę po odjęciu fee
))
tx.fee()
tx.sign()
response = await tx.broadcast(WhatsOnChainBroadcaster(NETWORK))
print(f"\nStatus: {response.status}")
print(f"TXID: {tx.txid()}")
def send_transaction():
if not wallet_exists():
print("Brak portfela.")
return
utxos = get_all_utxos(DB_FILE)
if not utxos:
print("Brak UTXO w bazie. Użyj opcji 2 żeby odświeżyć.")
return
print("\nDostępne UTXO:")
total_available = sum(u[2] for u in utxos)
for txid, vout, value_sats, address, path in utxos:
print(f" {txid[:20]}... | {value_sats} sat | {address}")
print(f"Łącznie dostępne: {total_available} sat\n")
output_address = input("Adres odbiorcy: ").strip()
if not validate_address(output_address):
print("Nieprawidłowy adres BSV. Sprawdź czy dobrze skopiowałeś.")
return
try:
amount_sats = int(input("Kwota do wysłania (satoshi): ").strip())
except ValueError:
print("Nieprawidłowa kwota.")
return
if amount_sats <= 0 or amount_sats > total_available:
print("Nieprawidłowa kwota lub za mało środków.")
return
# dobieramy UTXO od największego — mniej wejść = mniejsze fee
utxos_sorted = sorted(utxos, key=lambda u: u[2], reverse=True)
selected = []
selected_total = 0
for txid, vout, value_sats, address, path in utxos_sorted:
selected.append({"txid": txid, "vout": vout, "value_sats": value_sats,
"address": address, "amount_to_send": amount_sats})
selected_total += value_sats
if selected_total >= amount_sats:
break
print(f"\nWybrano {len(selected)} UTXO pokrywających {selected_total} sat.")
password = getpass("Hasło do keystore: ")
try:
mnemonic = load_mnemonic_from_keystore(password)
except Exception:
print("Błędne hasło.")
return
finally:
password = ""
inputs_data = []
for utxo in selected:
idx = get_address_index(utxo["address"], DB_FILE)
if idx is None:
print(f"Nie znaleziono indeksu dla {utxo['address']}")
mnemonic = ""
return
inputs_data.append({"txid": utxo["txid"], "vout": utxo["vout"],
"derivation_index": idx, "amount_to_send": amount_sats})
# adres reszty — kolejny wolny adres w naszym portfelu
next_index = get_max_derivation_index(change=0, db_path=DB_FILE) + 1
change_keys = bip44_derive_xprvs_from_mnemonic(
mnemonic, next_index, next_index + 1,
path=BIP44_DERIVATION_PATH, change=0, network="testnet",
)
change_address = change_keys[0].address()
insert_address(next_index, 0, change_address,
f"{BIP44_DERIVATION_PATH}/0/{next_index}", DB_FILE)
print(f"\nPodsumowanie:")
print(f" Odbiorca : {output_address}")
print(f" Kwota : {amount_sats} sat ({amount_sats / 1e8:.8f} BSV)")
print(f" Reszta na: {change_address}")
confirm = input("\nWysłać? (tak/nie): ").strip().lower()
if confirm != "tak":
print("Anulowano.")
mnemonic = ""
return
try:
asyncio.run(_broadcast(inputs_data, output_address, change_address, mnemonic))
except Exception as e:
print(f"Błąd podczas wysyłania: {e}")
finally:
mnemonic = ""
Kilka rzeczy wartych uwagi:
print("\n=== MENU PORTFELA BSV ===")
print("1. Utwórz nowy portfel")
print("2. Odblokuj portfel i wyświetl adresy")
print("3. Wygeneruj nowy adres")
print("4. Odśwież UTXO")
print("5. Wyślij transakcję")
print("6. Wyjście")
Cały kod umieściłem w repozytorium.
**Gwarantuję Ci niezmienność moich treści**
Hash artykułu:
ID transakcji: sprawdź OP_RETURN [tag Relax4000] i porównaj jego hash
Komentarze
Prześlij komentarz