← index

Stealth addresses using nostr

An archive of delvingbitcoin.org · view original topic →

/dev/fd0 · #1 ·

Nostr uses the same cryptography to generate keys as bitcoin. So, users can generate new keys from npub and receive bitcoin payments.

Motivation

Protocol

Alice would generate a new key for Bob and share the notification as encrypted message using NIP-17. Bob’s wallet will save the details to receive payments from Alice in future. Alice will get a new address for Bob by incrementing counter.

A BIP or NIP could be written to describe the specifications and below is a proof of concept:

Proof of Concept

#!/usr/bin/env python3

import hashlib
import hmac
import secrets
import json
import os
from typing import Tuple, Optional

from nostr.key import PrivateKey, PublicKey
import coincurve


class NostrKeyGenerator:
    
    def __init__(self, private_key_hex: str):
        if len(private_key_hex) != 64:
            raise ValueError("Private key must be 64 hex characters")
        
        try:
            private_key_bytes = bytes.fromhex(private_key_hex)
            self.private_key = PrivateKey(private_key_bytes)
            self.public_key = self.private_key.public_key
        except ValueError:
            raise ValueError("Invalid hex string for private key")
    
    def get_private_key_hex(self) -> str:
        return self.private_key.hex()
    
    def get_public_key_hex(self) -> str:
        return self.public_key.hex()
    
    def compute_shared_secret(self, other_public_key_hex: str) -> bytes:
        shared_secret = self.private_key.compute_shared_secret(other_public_key_hex)
        if isinstance(shared_secret, bytes):
            return shared_secret
        else:
            return bytes.fromhex(shared_secret)
    
    def generate_stealth_public_key(self, recipient_public_key_hex: str, counter: int = 0) -> str:
        shared_secret = self.compute_shared_secret(recipient_public_key_hex)
        counter_bytes = counter.to_bytes(4, 'big')
        
        key_factor = hmac.new(shared_secret, counter_bytes + b"stealth", hashlib.sha256).digest()
        
        recipient_key_bytes = bytes.fromhex('02' + recipient_public_key_hex)
        recipient_point = coincurve.PublicKey(recipient_key_bytes)
        
        factor_private_key = coincurve.PrivateKey(key_factor)
        factor_point = factor_private_key.public_key
        
        combined_point = recipient_point.combine([factor_point])
        
        compressed_pubkey = combined_point.format(compressed=True)
        return compressed_pubkey[1:].hex()
    
    def derive_stealth_private_key(self, sender_public_key_hex: str, counter: int = 0) -> Tuple[str, str]:
        shared_secret = self.compute_shared_secret(sender_public_key_hex)
        counter_bytes = counter.to_bytes(4, 'big')
        
        key_factor = hmac.new(shared_secret, counter_bytes + b"stealth", hashlib.sha256).digest()
        
        factor_int = int.from_bytes(key_factor, 'big')
        my_private_int = int(self.private_key.hex(), 16)
        
        secp256k1_order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
        new_private_int = (my_private_int + factor_int) % secp256k1_order
        
        new_private_bytes = new_private_int.to_bytes(32, 'big')
        new_private_key = PrivateKey(new_private_bytes)
        
        new_public_key = new_private_key.public_key
        
        coincurve_private = coincurve.PrivateKey(new_private_bytes)
        coincurve_public = coincurve_private.public_key
        compressed_pubkey = coincurve_public.format(compressed=True)
        
        return (new_private_bytes.hex(), compressed_pubkey[1:].hex())


def validate_hex_key(key_hex: str, key_type: str, expected_length: int) -> bool:
    if len(key_hex) != expected_length:
        print(f"Error: {key_type} must be {expected_length} hex characters")
        return False
    
    try:
        bytes.fromhex(key_hex)
        return True
    except ValueError:
        print(f"Error: {key_type} must be valid hex")
        return False


def generate_stealth_public_key():
    print("\n\033[34mYou are the SENDER. Generate a stealth public key for the recipient.\033[0m")
    print()
    
    sender_private = input("Enter your private key (64 hex chars): ").strip()
    if not validate_hex_key(sender_private, "Private key", 64):
        return
    
    recipient_public = input("Enter recipient's public key (64 hex chars): ").strip()
    if not validate_hex_key(recipient_public, "Public key", 64):
        return
    
    try:
        counter = int(input("Enter counter value (0-999, default 0): ").strip() or "0")
        if counter < 0 or counter > 999:
            print("Counter must be between 0 and 999")
            return
    except ValueError:
        print("Counter must be a number")
        return
    
    try:
        keygen = NostrKeyGenerator(sender_private)
        stealth_pubkey = keygen.generate_stealth_public_key(recipient_public, counter)
        
        print(f"\n\033[32mGenerated Stealth Public Key: {stealth_pubkey}\033[0m")
        print()
        print("\033[33mShare these details with the recipient:\033[0m")
        print(f"\033[33m   - Your public key: {keygen.get_public_key_hex()}\033[0m")
        print(f"\033[33m   - Counter used: {counter}\033[0m")
        
    except Exception as e:
        print(f"Error generating stealth public key: {e}")


def derive_stealth_private_key():
    print("\n\033[34mYou are the RECIPIENT. Derive the private key for a stealth public key.\033[0m")
    print()
    
    recipient_private = input("Enter your private key (64 hex chars): ").strip()
    if not validate_hex_key(recipient_private, "Private key", 64):
        return
    
    sender_public = input("Enter sender's public key (64 hex chars): ").strip()
    if not validate_hex_key(sender_public, "Public key", 64):
        return
    
    try:
        counter = int(input("Enter counter value used by sender: ").strip())
        if counter < 0 or counter > 999:
            print("Counter must be between 0 and 999")
            return
    except ValueError:
        print("Counter must be a number")
        return
    
    try:
        keygen = NostrKeyGenerator(recipient_private)
        stealth_private, stealth_public = keygen.derive_stealth_private_key(sender_public, counter)
        
        print(f"\n\033[95mDerived Stealth Private Key: {stealth_private}\033[0m")
        print(f"\033[32mDerived Stealth Public Key: {stealth_public}\033[0m")
        print()
        
    except Exception as e:
        print(f"Error deriving private key: {e}")


def generate_random_keypair():
    
    private_key_bytes = secrets.token_bytes(32)
    private_key = PrivateKey(private_key_bytes)
    
    print(f"\nPrivate Key: {private_key.hex()}")
    print(f"Public Key:  {private_key.public_key.hex()}")
    print()


def main():
    while True:
        print("\n" + "="*70)
        print("                    Nostr Stealth Key Generator")
        print("="*70)
        print("1. Generate random keypair")
        print("2. Get stealth public key")
        print("3. Get stealth private key")
        print("4. Exit")
        print("="*70)
        
        choice = input("Enter your choice (1-4): ").strip()
        
        if choice == "1":
            generate_random_keypair()
        elif choice == "2":
            generate_stealth_public_key()
        elif choice == "3":
            derive_stealth_private_key()
        elif choice == "4":
            break
        else:
            print("Invalid choice, please try again")


if __name__ == "__main__":
    main()

Adam Gibson · #2 ·

So, users can generate new keys from npub and receive bitcoin payments.

Just for ease of review, could you outline with equations what exactly the proposal consists of? What is in the notification? What’s the exact structure of the public key that Alice pays to? (like P = B\_{nostr} + H(aB\_{nostr}|i)G?) (I’m setting aside scan/spend distinction there because I think that was your intention, but I’m not sure). And is the idea to avoid scanning that Bob stores a batch of addresses for Alice that he can watch for? (Sorry if that last question misses the point, but I don’t understand in detail what you’re proposing here) (my interest here is more analyzing the structure from a crypto point of view, than arguing about what the right design choices are, which explains why I’m mostly interested in just seeing the proposal written out in detail, than discussing whether it’s good or not!).

/dev/fd0 · #3 · · in reply to #2

Shared secret S = aB = abG = baG

Stealth keys

Stealth public key:  P = B + H(S || i || "stealth")G
Stealth private key: p = b + H(S || i || "stealth") mod n

Notification (sent via NIP-17 encrypted DM) contains:

Bob can then reconstruct the stealth public key P himself using the formula above, private key and bitcoin address to watch.

Adam Gibson · #4 ·

Thanks.

One thing I was trying to clarify was this part of the description. In the above, is B a key that is being used as an npub? And A, also? (I guess the latter, not, because she wouldn’t need to send it perhaps?)

/dev/fd0 · #5 · · in reply to #4

Yes A and B are keys used as npub on nostr. Its not necessary to send it and would entirely depend on the protocol if someone uses this for stealth addresses.

setavenger · #6 ·

Interesting idea, I have a few quick thoughts to share. As somebody who works a lot on Silent Payments I find this particularly interesting.

How would you make sure that no transactions are “lost”? As nostr has no global state, several scenarios come to mind where somehow a notification is “lost” or missed.

The above issue could be somewhat mitigated if a wallet always makes sure it has checked for all i ± x the last received i. But this only helps if Bob receives several transactions from Alice. There seems to be no fallback for the receiver to find transactions (e.g. brute force searching for new payments).

Another worry I’d have are malicious actors flooding the receiver with fake notifications. Silent Payments in it’s “base case” checks Transactions accepted by a node, which has protection (e.g. PoW for confirmed txs) against transaction flooding.

What if Alice accidentally looses count of i/reuses an already used i? It’s the same two parties making the transaction which is not a big problem but probably not optimal either. A deterministic nonce could help but not sure what that could be in the nostr context.

Compared to Silent Payments your proposal has basically no scanning effort which is quite nice. But a clear downside I currently see is the statelessness of nostr. If the receiver does not get the notification he will not know that he ever received a payment.

I’m not deep in the weeds on nostr’s recent developments on private messaging without leaking metadata. It seems though that pairing your idea with silent payments could be quite interesting. i.e. I could see nostr notifications as an easy way to notify receivers of new Silent Payments. They could always fallback to a full chain scan if necessary (e.g. Bob suspects a missed transaction). My worries for nostr notifications were/are always the metadata leakage but I think some efforts were made to reduce that issue. Would be interested in hearing your opinions on the risk of metadata leakage when notifying receivers.

/dev/fd0 · #7 · · in reply to #6

  1. Notifications don’t need to be saved forever by the relays. Once received by the wallet, it can be saved offline.

  2. If multiple relays are used by the sender and recipient, they are unlikely to get lost. One of them could be run by the wallet developer.

Some common relays would exist if they are using the same wallet. Other wallets may agree on using the same list of relays.

There is no incentive for a relay to delete the event although broadcasting the event to multiple relays would avoid this. Confirmation message could also be used in the protocol.

Fake notifications can be managed by normal spam filtering used in other nostr clients and relays.

  1. Pairing the idea with silent payments sounds interesting and I had discussed it with @moonsettler.
  2. Metadata leaks are fixed and only exist in NIP 4.

Note: Electrum wallet already uses nostr relays in the wallet. So it will be easier to implement this as a plugin.

setavenger · #8 · · in reply to #7

I tried to make a little write up to structure what was discussed above. It might help others to better understand the idea presented here. I took the liberty to make some small changes or annotations where I believe some improvements could be made. I marked those with (*). Maybe some of the things were implied, marking them nonetheless.

Definitions

Sender: Alice (nsec: a npub: A)

Receiver Bob (nsec: b npub: B)

(*) Hash_tagged(x): SHA256(SHA256("stealth") || SHA256("stealth") || x) (Tagged hash like Bip340)

Counter: i, counter starting from 0. +1 for every pubkey Alice generates when sending to Bob

Crypto

Shared Secret (S): a * B or b * A (remember that parity matters here and the nostr protocol implies even parity for it’s keys)

Stealth component (c): Hash_tagged(S || i)

Stealth Address Pubkey: P = B + c * G

Stealth Secret Seckey: p = (b + c) mod n (where n is the curve order)

Alice encodes P to a taproot script and sends to it. Address = "5120 + P" where P is a 32 byte x-only pubkey.

Communication

Communication starts after Alice has already made the transaction

  1. Alice: notifies Bob of the transaction. The message needs to at least include the counter. But more information can/should be given to make finding the actual outpoint easier

  2. (*) Bob: upon receiving the notification should validate the utxo and store the pubkey

  3. (*) Bob: if (2) was successful send a simple confirmation message - could include the counter to avoid confusion with potential other transactions which Alice made to Bob

  4. (*) Alice: until receiving the confirmation message with a counter i should periodically rebroadcast the notification message

Backup (*)

Note: As a general statement: Bob has no option to find out whether all transactions made to him have been seen by him

Recovering from scratch:

Additional Notes

General opinionated ideas for improving the Stealth Address protocol

setavenger · #9 · · in reply to #7

I think I have addressed most of your points in the write up. One thing I’m currently not super convinced on is a wallet mainly based on nostr. More specifically a wallet that stores critical data within nostr without any sort of reliable fallback. I have heard of Cashu wallets doing something like that but have also heard of relays intentionally nuking their dbs.

There is also no incentive to not delete. Maybe I would even argue that storage requirements incentivize the deletion of notes (especially old ones).

Is there any information on that. I have never heard of NIP-17 seeing big adoption. If the NIP is mainly used for Stealth Addresses this would be a form of metadata leak.

I guess one could further argue that if Alice finds the npub of Bob and feels like sending to Bob there must have been some common hop between them to begin with.

/dev/fd0 · #10 · · in reply to #9

Thanks for the write up.

It doesn’t have to be a wallet based on nostr. You just need some relays in the settings and a library that could be used for several things. Electrum uses it for submarine swaps.

I am not sure about the adoption however I use it in amethyst often. It has solved the metada leak issues.

Whitenoise uses NIP-EE or MLS that you may find interesting: nips/EE.md at b516adbf423a120045e07adf5358ae69f190f3c8 · nostr-protocol/nips · GitHub


I will work on an electrum plugin that can be used to test this concept with a bitcoin wallet. I would call them color addresses that use colors as HRP instead of npub.

setavenger · #11 ·

@1440000bytes I have come up with a starting point for a spec which i eluded to in my comments. It melts your idea of notifications via Nostr with the standard Silent Payments protocol. Commenting in case this is still interesting for you.

/dev/fd0 · #12 · · in reply to #11

Thanks for the update and the use of nostr for notifications in silent payments. I have created an electrum plugin to test the idea shared in this thread: /dev/fd0 / color-address · GitLab

Demo: https://video.nostr.build/9ac082eab8512b43c14c75f219584c9ec5cd4b5f260bdff711577ee5f5261ad9.mp4

Notification:

    {
      "sender_pubkey": "7c9121158dc7f18caa96494773bcda6ee0c6b59e15ba6770b8447b847b83333f",
      "counter": 3,
      "stealth_address": "tb1qx2993hxuveywjznrf0h7htt072ss6g3xukgnal",
      "timestamp": 1768627132,
      "notification_id": "139129e1f85f0ec4c64bfa2f01b9a37a5f835d49a6567e6e7bb6a0a31f3363d9"
    }

Tx: mempool - Bitcoin Explorer

Archive

https://archive.is/yXlQe

/dev/fd0 · #13 ·

I don’t see any option to reply on your thread Silent Payments notifications via Nostr

However, there are people looking to use nostr for notifications: https://xcancel.com/francispouliot_/status/2019219267322015931

Cc: @setavenger

Archive

https://archive.is/DK3HX

setavenger · #14 · · in reply to #13

I am roughly aware of the linked project. It goes into a similar direction.

I’m not sure why you wouldn’t be able to reply on my delving thread, I have already received replies. Are there any hidden settings?

setavenger · #15 · · in reply to #12

Also nice work on the plugin. I’m still trying to find the time to properly look through it…

ArmchairCryptologist · #16 · · in reply to #14

I believe you need to join the Silent Payments Working Group to post in that category; see pinned thread.

securitybrahh · #17 ·

This looks very interesting, I was trying to make an electrum plugin for sending and receiving via silent payments GitHub - securitybrahh/silentpayments-electrum: Electrum plugin for recieving and sending silent payments · GitHub

The main issues are currently the server spec Collaboration: contributing to core SP logic + BIP376 PSBT compatibility · Issue #1 · securitybrahh/silentpayments-electrum · GitHub