How To Turn a Nostr Nsec Into a Hardened Non-Taproot Address In Prod
Previously…
Thanks again to ChatGPT, if you’re concerned about QC coming true & compromising your UTXOs under the exposed pubkey of even-y derived p2pkh & p2wpkh addresses from your Nostr nsec/npub…
Here’s how you take your nsec & fight QC with “perfectly okay” HKDF-SHA256 to get a passphrase-salted keypair:
#
# Completely dependency-free single-file script
#
# Features:
# - Decode Nostr nsec
# - Derive hardened BTC private key from:
# nsec + passphrase
# - Deterministic HKDF-SHA256 derivation
# - secp256k1 public key derivation
# - WIF generation
# - P2PKH address
# - P2WPKH address
#
# No pip installs required.
#
# Uses only:
# hashlib
# hmac
# secrets
#
# WARNING:
# This is educational/minimal code.
# Do not trust large funds to unaudited crypto code.
#
import hashlib
import hmac
# ============================================================
# Base58
# ============================================================
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def b58encode(b):
n = int.from_bytes(b, "big")
out = ""
while n > 0:
n, r = divmod(n, 58)
out = BASE58_ALPHABET[r] + out
pad = 0
for c in b:
if c == 0:
pad += 1
else:
break
return "1" * pad + out
# ============================================================
# Bech32
# ============================================================
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def bech32_polymod(values):
GEN = [
0x3b6a57b2,
0x26508e6d,
0x1ea119fa,
0x3d4233dd,
0x2a1462b3
]
chk = 1
for v in values:
top = chk >> 25
chk = ((chk & 0x1ffffff) << 5) ^ v
for i in range(5):
if ((top >> i) & 1):
chk ^= GEN[i]
return chk
def bech32_hrp_expand(hrp):
return [ord(x) >> 5 for x in hrp] + [0] + [
ord(x) & 31 for x in hrp
]
def bech32_verify_checksum(hrp, data):
return bech32_polymod(
bech32_hrp_expand(hrp) + data
) == 1
def bech32_decode(bech):
bech = bech.lower()
pos = bech.rfind("1")
if pos < 1:
return None, None
hrp = bech[:pos]
data = []
for c in bech[pos + 1:]:
if c not in BECH32_CHARSET:
return None, None
data.append(
BECH32_CHARSET.find(c)
)
if not bech32_verify_checksum(hrp, data):
return None, None
return hrp, data[:-6]
def bech32_create_checksum(hrp, data):
values = bech32_hrp_expand(hrp) + data
polymod = bech32_polymod(
values + [0, 0, 0, 0, 0, 0]
) ^ 1
return [
(polymod >> 5 * (5 - i)) & 31
for i in range(6)
]
def bech32_encode(hrp, data):
combined = data + bech32_create_checksum(
hrp,
data
)
return hrp + "1" + "".join(
[BECH32_CHARSET[d] for d in combined]
)
def convertbits(data, frombits, tobits, pad=True):
acc = 0
bits = 0
ret = []
maxv = (1 << tobits) - 1
for value in data:
acc = (acc << frombits) | value
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append(
(acc >> bits) & maxv
)
if pad:
if bits:
ret.append(
(acc << (tobits - bits)) & maxv
)
elif bits >= frombits or (
(acc << (tobits - bits)) & maxv
):
return None
return ret
# ============================================================
# secp256k1
# ============================================================
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
GX = 55066263022277343669578718895168534326250603453777594175500187360389116729240
GY = 32670510020758816978083085130507043184471273380659243275938904335757337482424
def inverse_mod(a, p):
return pow(a, p - 2, p)
def point_add(p1, p2):
if p1 is None:
return p2
if p2 is None:
return p1
x1, y1 = p1
x2, y2 = p2
if x1 == x2 and y1 != y2:
return None
if p1 == p2:
m = (
(3 * x1 * x1)
* inverse_mod(2 * y1, P)
) % P
else:
m = (
(y2 - y1)
* inverse_mod(x2 - x1, P)
) % P
x3 = (m * m - x1 - x2) % P
y3 = (m * (x1 - x3) - y1) % P
return (x3, y3)
def scalar_mult(k, point):
result = None
addend = point
while k:
if k & 1:
result = point_add(result, addend)
addend = point_add(addend, addend)
k >>= 1
return result
# ============================================================
# Pure Python RIPEMD160
# ============================================================
def _rol(x, n):
return ((x << n) | (x >> (32 - n))) & 0xffffffff
def ripemd160(msg):
# --------------------------------------------------------
# Constants
# --------------------------------------------------------
r1 = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,15,
7, 4,13, 1,10, 6,15, 3,12, 0, 9, 5, 2,14,11, 8,
3,10,14, 4, 9,15, 8, 1, 2, 7, 0, 6,13,11, 5,12,
1, 9,11,10, 0, 8,12, 4,13, 3, 7,15,14, 5, 6, 2,
4, 0, 5, 9, 7,12, 2,10,14, 1, 3, 8,11, 6,15,13
]
r2 = [
5,14, 7, 0, 9, 2,11, 4,13, 6,15, 8, 1,10, 3,12,
6,11, 3, 7, 0,13, 5,10,14,15, 8,12, 4, 9, 1, 2,
15, 5, 1, 3, 7,14, 6, 9,11, 8,12, 2,10, 0, 4,13,
8, 6, 4, 1, 3,11,15, 0, 5,12, 2,13, 9, 7,10,14,
12,15,10, 4, 1, 5, 8, 7, 6, 2,13,14, 0, 3, 9,11
]
s1 = [
11,14,15,12, 5, 8, 7, 9,11,13,14,15, 6, 7, 9, 8,
7, 6, 8,13,11, 9, 7,15, 7,12,15, 9,11, 7,13,12,
11,13, 6, 7,14, 9,13,15,14, 8,13, 6, 5,12, 7, 5,
11,12,14,15,14,15, 9, 8, 9,14, 5, 6, 8, 6, 5,12,
9,15, 5,11, 6, 8,13,12, 5,12,13,14,11, 8, 5, 6
]
s2 = [
8, 9, 9,11,13,15,15, 5, 7, 7, 8,11,14,14,12, 6,
9,13,15, 7,12, 8, 9,11, 7, 7,12, 7, 6,15,13,11,
9, 7,15,11, 8, 6, 6,14,12,13, 5,14,13,13, 7, 5,
15, 5, 8,11,14,14, 6,14, 6, 9,12, 9,12, 5,15, 8,
8, 5,12, 9,12, 5,14, 6, 8,13, 6, 5,15,13,11,11
]
# --------------------------------------------------------
# Functions
# --------------------------------------------------------
def f(j, x, y, z):
if 0 <= j <= 15:
return x ^ y ^ z
if 16 <= j <= 31:
return (x & y) | (~x & z)
if 32 <= j <= 47:
return (x | ~y) ^ z
if 48 <= j <= 63:
return (x & z) | (y & ~z)
return x ^ (y | ~z)
def K1(j):
if 0 <= j <= 15:
return 0x00000000
if 16 <= j <= 31:
return 0x5A827999
if 32 <= j <= 47:
return 0x6ED9EBA1
if 48 <= j <= 63:
return 0x8F1BBCDC
return 0xA953FD4E
def K2(j):
if 0 <= j <= 15:
return 0x50A28BE6
if 16 <= j <= 31:
return 0x5C4DD124
if 32 <= j <= 47:
return 0x6D703EF3
if 48 <= j <= 63:
return 0x7A6D76E9
return 0x00000000
# --------------------------------------------------------
# Padding
# --------------------------------------------------------
ml = len(msg) * 8
msg += b"\x80"
while (len(msg) % 64) != 56:
msg += b"\x00"
msg += ml.to_bytes(8, "little")
# --------------------------------------------------------
# Initial state
# --------------------------------------------------------
h0 = 0x67452301
h1 = 0xEFCDAB89
h2 = 0x98BADCFE
h3 = 0x10325476
h4 = 0xC3D2E1F0
# --------------------------------------------------------
# Process blocks
# --------------------------------------------------------
for offset in range(0, len(msg), 64):
block = msg[offset:offset + 64]
X = [
int.from_bytes(
block[i:i+4],
"little"
)
for i in range(0, 64, 4)
]
A1 = h0
B1 = h1
C1 = h2
D1 = h3
E1 = h4
A2 = h0
B2 = h1
C2 = h2
D2 = h3
E2 = h4
for j in range(80):
T = (
_rol(
(
A1
+ f(j, B1, C1, D1)
+ X[r1[j]]
+ K1(j)
) & 0xffffffff,
s1[j]
)
+ E1
) & 0xffffffff
A1, E1, D1, C1, B1 = (
E1,
D1,
_rol(C1, 10),
B1,
T
)
T = (
_rol(
(
A2
+ f(79 - j, B2, C2, D2)
+ X[r2[j]]
+ K2(j)
) & 0xffffffff,
s2[j]
)
+ E2
) & 0xffffffff
A2, E2, D2, C2, B2 = (
E2,
D2,
_rol(C2, 10),
B2,
T
)
T = (h1 + C1 + D2) & 0xffffffff
h1 = (h2 + D1 + E2) & 0xffffffff
h2 = (h3 + E1 + A2) & 0xffffffff
h3 = (h4 + A1 + B2) & 0xffffffff
h4 = (h0 + B1 + C2) & 0xffffffff
h0 = T
return (
h0.to_bytes(4, "little")
+ h1.to_bytes(4, "little")
+ h2.to_bytes(4, "little")
+ h3.to_bytes(4, "little")
+ h4.to_bytes(4, "little")
)
# ============================================================
# HASH160
# ============================================================
def hash160(data):
sha = hashlib.sha256(data).digest()
return ripemd160(sha)
# ============================================================
# Decode nsec
# ============================================================
def nsec_to_privkey(nsec):
hrp, data = bech32_decode(nsec)
if hrp != "nsec":
raise ValueError("Invalid nsec")
decoded = convertbits(
data,
5,
8,
False
)
if decoded is None:
raise ValueError("Bad convertbits")
raw = bytes(decoded)
if len(raw) != 32:
raise ValueError("Expected 32-byte key")
return raw
# ============================================================
# HKDF-SHA256
# ============================================================
def hkdf_extract(salt, ikm):
return hmac.new(
salt,
ikm,
hashlib.sha256
).digest()
def hkdf_expand(prk, info, length=32):
output = b""
t = b""
counter = 1
while len(output) < length:
t = hmac.new(
prk,
t + info + bytes([counter]),
hashlib.sha256
).digest()
output += t
counter += 1
return output[:length]
def derive_hardened_btc_privkey(
nsec,
passphrase
):
nostr_privkey = nsec_to_privkey(
nsec
)
salt = hashlib.sha256(
passphrase.encode()
).digest()
prk = hkdf_extract(
salt,
nostr_privkey
)
return hkdf_expand(
prk,
b"nostr-to-bitcoin-v1",
32
)
# ============================================================
# Pubkey
# ============================================================
def privkey_to_pubkey(privkey_bytes):
k = int.from_bytes(
privkey_bytes,
"big"
)
x, y = scalar_mult(
k,
(GX, GY)
)
prefix = b"\x02" if y % 2 == 0 else b"\x03"
return prefix + x.to_bytes(32, "big")
# ============================================================
# WIF
# ============================================================
def privkey_to_wif(privkey):
payload = (
b"\x80"
+ privkey
+ b"\x01"
)
checksum = hashlib.sha256(
hashlib.sha256(payload).digest()
).digest()[:4]
return b58encode(
payload + checksum
)
# ============================================================
# P2PKH
# ============================================================
def pubkey_to_p2pkh(pubkey):
h160 = hash160(pubkey)
payload = b"\x00" + h160
checksum = hashlib.sha256(
hashlib.sha256(payload).digest()
).digest()[:4]
return b58encode(
payload + checksum
)
# ============================================================
# P2WPKH
# ============================================================
def pubkey_to_p2wpkh(pubkey):
h160 = hash160(pubkey)
data = [0] + convertbits(
h160,
8,
5
)
return bech32_encode(
"bc",
data
)
# ============================================================
# MAIN
# ============================================================
if __name__ == "__main__":
my_nsec = "YOUR_NSEC_HERE"
passphrase = "correct horse battery staple"
btc_privkey = derive_hardened_btc_privkey(
my_nsec,
passphrase
)
wif = privkey_to_wif(
btc_privkey
)
pubkey = privkey_to_pubkey(
btc_privkey
)
p2pkh = pubkey_to_p2pkh(
pubkey
)
p2wpkh = pubkey_to_p2wpkh(
pubkey
)
print()
print("===== Hardened BTC Derivation =====")
print()
print("WIF:")
print(wif)
print()
print("Compressed Public Key:")
print(pubkey.hex())
print()
print("P2PKH:")
print(p2pkh)
print()
print("P2WPKH:")
print(p2wpkh)
print()
And Argon2id, better against GPU/ASIC cracking (install dependencies: pip install base58 bech32 ecdsa argon2-cffi):
import hashlib
import base58
from bech32 import bech32_encode, bech32_decode, convertbits
from ecdsa import SigningKey, SECP256k1
from argon2.low_level import hash_secret_raw, Type
# ============================================================
# Decode Nostr nsec
# ============================================================
def nsec_to_privkey(nsec_str):
hrp, data5 = bech32_decode(nsec_str)
if hrp != "nsec":
raise ValueError("Invalid nsec")
return bytes(convertbits(data5, 5, 8, False))
# ============================================================
# Argon2id hardened derivation
# ============================================================
def derive_hardened_btc_privkey(nsec_str, passphrase):
nostr_privkey = nsec_to_privkey(nsec_str)
# Domain separation
domain = b"nostr-to-bitcoin-v1"
# Salt for Argon2id
#
# Deterministic:
# same nsec + same passphrase => same wallet
#
salt = hashlib.sha256(
domain + passphrase.encode()
).digest()
# Argon2id derivation
#
# memory_cost is in KiB
#
derived_key = hash_secret_raw(
secret=nostr_privkey,
salt=salt,
time_cost=6,
memory_cost=262144, # 256 MiB
parallelism=1,
hash_len=32,
type=Type.ID
)
return derived_key
# ============================================================
# Pure Python RIPEMD160
# ============================================================
def _rol(x, n):
return ((x << n) | (x >> (32 - n))) & 0xffffffff
def ripemd160(msg):
# --------------------------------------------------------
# Constants
# --------------------------------------------------------
r1 = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,15,
7, 4,13, 1,10, 6,15, 3,12, 0, 9, 5, 2,14,11, 8,
3,10,14, 4, 9,15, 8, 1, 2, 7, 0, 6,13,11, 5,12,
1, 9,11,10, 0, 8,12, 4,13, 3, 7,15,14, 5, 6, 2,
4, 0, 5, 9, 7,12, 2,10,14, 1, 3, 8,11, 6,15,13
]
r2 = [
5,14, 7, 0, 9, 2,11, 4,13, 6,15, 8, 1,10, 3,12,
6,11, 3, 7, 0,13, 5,10,14,15, 8,12, 4, 9, 1, 2,
15, 5, 1, 3, 7,14, 6, 9,11, 8,12, 2,10, 0, 4,13,
8, 6, 4, 1, 3,11,15, 0, 5,12, 2,13, 9, 7,10,14,
12,15,10, 4, 1, 5, 8, 7, 6, 2,13,14, 0, 3, 9,11
]
s1 = [
11,14,15,12, 5, 8, 7, 9,11,13,14,15, 6, 7, 9, 8,
7, 6, 8,13,11, 9, 7,15, 7,12,15, 9,11, 7,13,12,
11,13, 6, 7,14, 9,13,15,14, 8,13, 6, 5,12, 7, 5,
11,12,14,15,14,15, 9, 8, 9,14, 5, 6, 8, 6, 5,12,
9,15, 5,11, 6, 8,13,12, 5,12,13,14,11, 8, 5, 6
]
s2 = [
8, 9, 9,11,13,15,15, 5, 7, 7, 8,11,14,14,12, 6,
9,13,15, 7,12, 8, 9,11, 7, 7,12, 7, 6,15,13,11,
9, 7,15,11, 8, 6, 6,14,12,13, 5,14,13,13, 7, 5,
15, 5, 8,11,14,14, 6,14, 6, 9,12, 9,12, 5,15, 8,
8, 5,12, 9,12, 5,14, 6, 8,13, 6, 5,15,13,11,11
]
# --------------------------------------------------------
# Functions
# --------------------------------------------------------
def f(j, x, y, z):
if 0 <= j <= 15:
return x ^ y ^ z
if 16 <= j <= 31:
return (x & y) | (~x & z)
if 32 <= j <= 47:
return (x | ~y) ^ z
if 48 <= j <= 63:
return (x & z) | (y & ~z)
return x ^ (y | ~z)
def K1(j):
if 0 <= j <= 15:
return 0x00000000
if 16 <= j <= 31:
return 0x5A827999
if 32 <= j <= 47:
return 0x6ED9EBA1
if 48 <= j <= 63:
return 0x8F1BBCDC
return 0xA953FD4E
def K2(j):
if 0 <= j <= 15:
return 0x50A28BE6
if 16 <= j <= 31:
return 0x5C4DD124
if 32 <= j <= 47:
return 0x6D703EF3
if 48 <= j <= 63:
return 0x7A6D76E9
return 0x00000000
# --------------------------------------------------------
# Padding
# --------------------------------------------------------
ml = len(msg) * 8
msg += b"\x80"
while (len(msg) % 64) != 56:
msg += b"\x00"
msg += ml.to_bytes(8, "little")
# --------------------------------------------------------
# Initial state
# --------------------------------------------------------
h0 = 0x67452301
h1 = 0xEFCDAB89
h2 = 0x98BADCFE
h3 = 0x10325476
h4 = 0xC3D2E1F0
# --------------------------------------------------------
# Process blocks
# --------------------------------------------------------
for offset in range(0, len(msg), 64):
block = msg[offset:offset + 64]
X = [
int.from_bytes(
block[i:i+4],
"little"
)
for i in range(0, 64, 4)
]
A1 = h0
B1 = h1
C1 = h2
D1 = h3
E1 = h4
A2 = h0
B2 = h1
C2 = h2
D2 = h3
E2 = h4
for j in range(80):
T = (
_rol(
(
A1
+ f(j, B1, C1, D1)
+ X[r1[j]]
+ K1(j)
) & 0xffffffff,
s1[j]
)
+ E1
) & 0xffffffff
A1, E1, D1, C1, B1 = (
E1,
D1,
_rol(C1, 10),
B1,
T
)
T = (
_rol(
(
A2
+ f(79 - j, B2, C2, D2)
+ X[r2[j]]
+ K2(j)
) & 0xffffffff,
s2[j]
)
+ E2
) & 0xffffffff
A2, E2, D2, C2, B2 = (
E2,
D2,
_rol(C2, 10),
B2,
T
)
T = (h1 + C1 + D2) & 0xffffffff
h1 = (h2 + D1 + E2) & 0xffffffff
h2 = (h3 + E1 + A2) & 0xffffffff
h3 = (h4 + A1 + B2) & 0xffffffff
h4 = (h0 + B1 + C2) & 0xffffffff
h0 = T
return (
h0.to_bytes(4, "little")
+ h1.to_bytes(4, "little")
+ h2.to_bytes(4, "little")
+ h3.to_bytes(4, "little")
+ h4.to_bytes(4, "little")
)
# ============================================================
# HASH160
# ============================================================
def hash160(data):
sha = hashlib.sha256(data).digest()
return ripemd160(sha)
# ============================================================
# Compressed SEC pubkey
# ============================================================
def privkey_to_compressed_pubkey(privkey_bytes):
sk = SigningKey.from_string(
privkey_bytes,
curve=SECP256k1
)
vk = sk.verifying_key
x = vk.pubkey.point.x()
y = vk.pubkey.point.y()
prefix = b"\x02" if y % 2 == 0 else b"\x03"
return prefix + x.to_bytes(32, "big")
# ============================================================
# WIF
# ============================================================
def privkey_to_wif(privkey_bytes, compressed=True):
payload = b"\x80" + privkey_bytes
if compressed:
payload += b"\x01"
checksum = hashlib.sha256(
hashlib.sha256(payload).digest()
).digest()[:4]
return base58.b58encode(
payload + checksum
).decode()
# ============================================================
# P2PKH
# ============================================================
def pubkey_to_p2pkh(pubkey_bytes):
h160 = hash160(pubkey_bytes)
payload = b"\x00" + h160
checksum = hashlib.sha256(
hashlib.sha256(payload).digest()
).digest()[:4]
return base58.b58encode(
payload + checksum
).decode()
# ============================================================
# Native SegWit P2WPKH
# ============================================================
def pubkey_to_p2wpkh(pubkey_bytes):
h160 = hash160(pubkey_bytes)
data = [0] + convertbits(h160, 8, 5)
return bech32_encode("bc", data)
# ============================================================
# MAIN
# ============================================================
if __name__ == "__main__":
my_nsec = "YOUR_NSEC_HERE"
passphrase = "correct horse battery staple"
# Derive hardened BTC private key
btc_privkey = derive_hardened_btc_privkey(
my_nsec,
passphrase
)
# WIF
wif = privkey_to_wif(btc_privkey)
# Compressed pubkey
pubkey = privkey_to_compressed_pubkey(
btc_privkey
)
# Addresses
p2pkh = pubkey_to_p2pkh(pubkey)
p2wpkh = pubkey_to_p2wpkh(pubkey)
# Output
print()
print("===== Hardened BTC Derivation =====")
print()
print("WIF:")
print(wif)
print()
print("Compressed Public Key:")
print(pubkey.hex())
print()
print("P2PKH Address (Legacy):")
print(p2pkh)
print()
print("P2WPKH Address (Native SegWit):")
print(p2wpkh)
print()
Per ChatGPT:
The Argon2id parameters above are intentionally fairly expensive:
time_cost=6
memory_cost=262144 # 256 MiB
But you can tune them:
| Device | Suggested memory_cost |
|---|---|
| low-RAM VPS | 65536 |
| laptop/desktop | 262144 |
| high-end workstation | 524288+ |
The passphrase is NOT merely “extra entropy”; it becomes part of the Argon2id salt namespace.
That means:
-
identical nsec
-
but different passphrase
produces completely unrelated Bitcoin wallets.
And:
-
leaked npub
-
leaked nsec
-
future secp256k1 break
still do not reveal the Bitcoin private key without the passphrase.
Is this the end of the series? Please let it be…
Write a comment