How To Turn a Nostr Nsec Into a Hardened Non-Taproot Address In Prod
Previously…
Thanks again to ChatGPT, if you’re concerned about CRQC coming true & compromising your UTXOs under the exposed pubkey of even-y derived p2pkh & p2wpkh addresses from your Nostr nsec/npub…
(And obviously, if you’re concerned about CRQC at all, you aren’t touching Taproot/p2tr/bc1p…)
Here’s how you take your nsec & fight CRQC 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…
Looking for comments…
Searching Nostr relays. This may take a moment the first time this article is opened.
Looking for comments…
Searching Nostr relays. This may take a moment the first time this article is opened.