commit b656502e4e5ca9a90d0334c9518b5899b123feb8 Author: BTCforPlebs Date: Sat Oct 11 09:24:50 2025 -0400 0.0.1 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c08e6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +index.html +info.html diff --git a/nip-49decrypt b/nip-49decrypt new file mode 100755 index 0000000..7d9fad4 --- /dev/null +++ b/nip-49decrypt @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +A friendly CLI for NIP-49 encrypt/decrypt and key conversions. +""" + +import os +import sys +import unicodedata +from getpass import getpass + +try: + from bech32 import bech32_decode, bech32_encode, convertbits +except ImportError: + print("⚠️ Missing required package 'bech32'.\n" + "Run: pip install bech32\n", file=sys.stderr) + sys.exit(1) + +try: + from ecdsa import SigningKey, SECP256k1 +except ImportError: + print("⚠️ Missing required package 'ecdsa'.\n" + "Run: pip install ecdsa\n", file=sys.stderr) + sys.exit(1) + +try: + from nacl.bindings import crypto_aead_xchacha20poly1305_ietf_encrypt, crypto_aead_xchacha20poly1305_ietf_decrypt + have_xchacha = True +except ImportError: + have_xchacha = False + +try: + from cryptography.hazmat.primitives.ciphers.aead import XChaCha20Poly1305 + have_crypto_x = True +except ImportError: + have_crypto_x = False + +if not (have_xchacha or have_crypto_x): + print("❌ No XChaCha20-Poly1305 implementation available.\n" + "Install either 'pynacl' or 'cryptography' packages.", file=sys.stderr) + sys.exit(1) + +from cryptography.hazmat.primitives.kdf.scrypt import Scrypt + +HRP_NCRYPTSEC = "ncryptsec" +HRP_NSEC = "nsec" +HRP_NPUB = "npub" + + +def clear_terminal(): + os.system("clear" if os.name != "nt" else "cls") + + +def pause(prompt="[Enter] to continue"): + input(f"\n{prompt}") + + +def _normalize_password(password: str) -> str: + return unicodedata.normalize('NFKC', password) + + +def _derive_key(password: str, salt: bytes, logn: int) -> bytes: + pw_norm = _normalize_password(password).encode('utf-8') + N = 2 ** logn + kdf = Scrypt(salt=salt, length=32, n=N, r=8, p=1) + return kdf.derive(pw_norm) + + +def _bech32_decode_to_bytes(b32: str, expected_hrp: str) -> bytes: + hrp, data = bech32_decode(b32) + if hrp is None or data is None: + raise ValueError("Invalid Bech32 encoding") + if hrp != expected_hrp: + raise ValueError(f"Unexpected bech32 prefix: '{hrp}' (expected '{expected_hrp}')") + raw = convertbits(data, 5, 8, False) + if raw is None: + raise ValueError("Bech32 convertbits failed (5→8)") + return bytes(raw) + + +def _bech32_encode_from_bytes(hrp: str, data: bytes) -> str: + words = convertbits(list(data), 8, 5, True) + if words is None: + raise ValueError("Bech32 convertbits failed (8→5)") + return bech32_encode(hrp, words) + + +def encrypt_nip49(sec: bytes, password: str, logn: int = 16, ksb: int = 0x02) -> str: + if len(sec) != 32: + raise ValueError("Private key must be 32 bytes") + salt = os.urandom(16) + key = _derive_key(password, salt, logn) + nonce = os.urandom(24) + aad = bytes([ksb]) + if have_xchacha: + ct_and_tag = crypto_aead_xchacha20poly1305_ietf_encrypt(sec, aad, nonce, key) + else: + cipher = XChaCha20Poly1305(key) + ct_and_tag = cipher.encrypt(nonce, sec, aad) + version_byte = bytes([0x02]) + logn_byte = bytes([logn]) + payload = version_byte + logn_byte + salt + nonce + aad + ct_and_tag + return _bech32_encode_from_bytes(HRP_NCRYPTSEC, payload) + + +def decrypt_nip49(ncryptsec: str, password: str) -> bytes: + b = _bech32_decode_to_bytes(ncryptsec, HRP_NCRYPTSEC) + if len(b) < (1 + 1 + 16 + 24 + 1 + 16): + raise ValueError("Invalid payload length") + version = b[0] + logn = b[1] + if version != 0x02: + raise ValueError(f"Invalid version {version}, expected 0x02") + salt = b[2:18] + nonce = b[18:42] + ksb = b[42] + aad = bytes([ksb]) + ciphertext = b[43:] + key = _derive_key(password, salt, logn) + if have_xchacha: + plaintext = crypto_aead_xchacha20poly1305_ietf_decrypt(ciphertext, aad, nonce, key) + else: + cipher = XChaCha20Poly1305(key) + plaintext = cipher.decrypt(nonce, ciphertext, aad) + if len(plaintext) != 32: + raise ValueError("Decrypted plaintext length invalid") + return plaintext + + +def private_bytes_to_nsec(sec: bytes) -> str: + return _bech32_encode_from_bytes(HRP_NSEC, sec) + + +def nsec_to_private_bytes(nsec: str) -> bytes: + return _bech32_decode_to_bytes(nsec, HRP_NSEC) + + +def private_key_to_public_key_hex(privkey_bytes: bytes) -> str: + sk = SigningKey.from_string(privkey_bytes, curve=SECP256k1) + vk = sk.get_verifying_key() + return vk.to_string("compressed").hex() + + +def public_key_to_npub(pubkey_bytes: bytes) -> str: + return _bech32_encode_from_bytes(HRP_NPUB, pubkey_bytes) + + +def welcome(): + clear_terminal() + print("=" * 40) + print("🔐 NIP-49 Encrypt/Decrypt & Key Conversion CLI") + print("=" * 40) + print() + + +def prompt_mode(): + print("Choose an action:") + print(" (d) Decrypt an encrypted key with password") + print(" (e) Encrypt a private key with password") + print(" (h) Convert private key hex to nsec") + print(" (p) Convert public key hex to npub") + print(" (q) Quit") + return input("Your choice? ").strip().lower() + + +def main(): + welcome() + + while True: + mode = prompt_mode() + + if mode == 'q': + print("\nGoodbye!\n") + break + + elif mode == 'd': + enc = input("\nEnter ncryptsec: ").strip() + pwd = getpass("Password: ") + try: + decrypted = decrypt_nip49(enc, pwd) + print("\nDecrypted raw private key (hex):") + print(decrypted.hex()) + print("\nBech32 nsec:") + print(private_bytes_to_nsec(decrypted)) + except Exception as e: + print(f"Decryption failed: {e}") + + pause() + clear_terminal() + + elif mode == 'e': + inp = input("\nEnter your nsec or raw private key hex: ").strip() + if inp.startswith(HRP_NSEC + "1"): + try: + sec = nsec_to_private_bytes(inp) + except Exception as e: + print(f"Invalid nsec input: {e}") + pause() + clear_terminal() + continue + else: + try: + sec = bytes.fromhex(inp) + except Exception: + print("Invalid hex input.") + pause() + clear_terminal() + continue + if len(sec) != 32: + print("Private key must be exactly 32 bytes.") + pause() + clear_terminal() + continue + pwd = getpass("Password: ") + ksb_str = input("Key security byte (0/1/2) [default 2]: ").strip() + ksb = 0x02 + if ksb_str: + try: + ksb = int(ksb_str) + except ValueError: + print("Invalid input for key security byte; using default 2.") + logn_str = input("logn [default 16]: ").strip() + logn = 16 + if logn_str: + try: + logn = int(logn_str) + except ValueError: + print("Invalid logn input; using default 16.") + try: + encrypted = encrypt_nip49(sec, pwd, logn=logn, ksb=ksb) + print("\nEncrypted ncryptsec:") + print(encrypted) + except Exception as e: + print(f"Encryption failed: {e}") + + pause() + clear_terminal() + + elif mode == 'h': + hex_key = input("\nEnter private key hex (64 hex chars): ").strip() + try: + sec = bytes.fromhex(hex_key) + except Exception: + print("Invalid hex input.") + pause() + clear_terminal() + continue + if len(sec) != 32: + print("Private key must be exactly 32 bytes.") + pause() + clear_terminal() + continue + nsec = private_bytes_to_nsec(sec) + print("\nBech32 nsec:") + print(nsec) + + pause() + clear_terminal() + + elif mode == 'p': + pub_hex = input("\nEnter public key hex (compressed 33 bytes / 66 hex chars or uncompressed 65 bytes / 130 hex chars): ").strip() + try: + pub_bytes = bytes.fromhex(pub_hex) + except Exception: + print("Invalid hex input.") + pause() + clear_terminal() + continue + if len(pub_bytes) not in (33, 65): + print("Public key must be 33 bytes (compressed) or 65 bytes (uncompressed).") + pause() + clear_terminal() + continue + try: + npub = public_key_to_npub(pub_bytes) + print("\nBech32 npub:") + print(npub) + except Exception as e: + print(f"Failed to convert public key hex to npub: {e}") + + pause() + clear_terminal() + + else: + print("Invalid choice. Please select 'd', 'e', 'h', 'p', or 'q'.") + pause() + clear_terminal() + + +if __name__ == "__main__": + main() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b6d1c0c --- /dev/null +++ b/readme.md @@ -0,0 +1,105 @@ +# 🔐 NIP‑49 Key Encrypt/Decrypt & Conversion CLI + +A tiny, no-fuss command-line tool to **encrypt**, **decrypt**, and **convert** Nostr keys — safely and offline. + +Built for privacy-conscious users who want a secure and user-friendly way to handle their Nostr keys using [NIP‑49](https://github.com/nostr-protocol/nips/blob/master/49.md). + +--- + +## ✨ Features + +- 🔐 **Encrypt / Decrypt** your Nostr `nsec` keys using a password (NIP‑49) +- 🔁 **Convert** private key hex ↔ `nsec` and public key hex ↔ `npub` +- 🧭 Friendly CLI with step-by-step wizard prompts +- ⚙️ Pure Python. No compiling, no GUI — just run and go +- 🔒 Secure: works 100% offline — no network requests + +--- + +## 🍎 macOS Setup + +### ✅ 1. Install Python 3 (via Homebrew) + +If you don’t already have Python 3: + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +brew install python +``` +Confirm it’s working: +```bash +python3 --version +``` +📦 Installation +```bash +📁 1. Clone the repo +git clone https://github.com/btcforplebs/nip49-decrypt.git +``` +cd nip49-decrypt +### 🧪 2. Create and activate a virtual environment +```bash +python3 -m venv venv +source venv/bin/activate +``` +### 📦 3. Install Python packages +```bash +pip install bech32 cryptography ecdsa pynacl +If pynacl fails, the script will fall back to cryptography for encryption. +``` +### ✅ 4. Make it executable +```bash +chmod +x nip-49decrypt +``` +▶️ Usage +From the project directory, run: +```bash +./nip-49decrypt +``` +You’ll see a friendly menu: +```bash +Choose an action: + (d) Decrypt an encrypted key with password + (e) Encrypt a private key with password + (h) Convert private key hex to nsec + (p) Convert public key hex to npub + (q) Quit + ``` +### 🔐 Example: Decrypt a Key +Select (d) +Paste your ncryptsec string +Enter your password (input hidden) +View your decrypted raw private key and corresponding nsec + +### 🛠️ Other Features +Convert raw hex → nsec +Choose (h) and enter your private key as 64-character hex. +Convert public key hex → npub +Choose (p) and paste a 66-character (compressed) or 130-character (uncompressed) public key in hex. +Encrypt nsec → ncryptsec +Choose (e) and paste your private key (hex or nsec). You’ll be prompted for a password, and the script will return a password-encrypted ncryptsec. + +### 🧼 To Exit +Use (q) or press Ctrl + C at any time. + +### 📁 Files +File Description +nip-49decrypt Main Python script +README.md This help file +venv/ Your virtual environment (optional) + +### 🧠 FAQ +❓ Is this secure? +✅ Yes. This script never sends anything over the internet. +❓ Can I run it offline? +✅ 100%. Works without an internet connection. + +❓ What if a Python package is missing? +🛠️ The script will detect it and print exact instructions for how to install it. + +❓ Can I compile this to a single file? +Yes! You can use tools like pyinstaller to build a standalone binary. + +### 🤝 Credits +Built with ❤️ by plebs, for plebs. Inspired by NIP‑49 and the Nostr community. +### 🛡️ License +MIT — Free for all. Fork, contribute, share. \ No newline at end of file