Deciphering Nostr and it's private keys

I’ve heard of https://nostr.com for about a year, but not until recently have I experimenting with it.

This post consists of my rough notes as I progressed to sending a nostr message. If you are interested in a bare minimum way to post events to nostr, read on.

Generate a new private key

$ openssl ecparam -name secp256k1 -genkey -out ec-priv.pem

The output here as the file extension notes is PEM:

-----BEGIN EC PARAMETERS-----
BgUrgQQACg==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHQCAQEEIIREDACTEDLdWBV8ZYWgcKMYEQfGwSoGzS4/III38/yfoAcGBSuBBAAK
oUQDQgAEwVd01qcESxn5R+X2rJZgX/REDACTED5X0AorDesvtCmPrhh69
qhcl+qWSgKnz8XMHVU5wy/GgDj/kjg==
-----END EC PRIVATE KEY-----

Inspect the private key as hex

$ openssl ec -in ec-priv.pem -text -noout
read EC key
Private-Key: (256 bit)
priv:
    6f:72:f7:f6:ea:d1:5f:e5:RE:DA:CT:ED:d6:aa:6f:
    53:a6:16:ae:05:14:2e:60:ec:82:ec:c9:fd:09:3c:
    7f:51
pub:
    04:8b:7c:1d:2e:a7:29:1f:RE:DA:CT:ED:25:27:ca:
    b1:a8:a9:d1:58:6f:7b:0f:7a:1f:8e:26:2d:29:7d:
    3a:d2:1a:d7:1e:7e:82:be:1d:67:9d:80:ee:a6:92:
    db:7d:71:a5:7a:1e:e4:86:33:08:58:3a:42:de:d8:
    40:9c:13:3d:83
ASN1 OID: secp256k1

As a learning excercise let’s take this output, and by hand build the nostr expected private and public key.

The priv key is 32 bytes:

6f:72:f7:f6:ea:d1:5f:e5:RE:DA:CT:ED:d6:aa:6f:53:a6:16:ae:05:14:2e:60:ec:82:ec:c9:fd:09:3c:7f:51

Shortened by removing : delimiters

6f72f7f6ead15fe5REDACTED6aa6f53a616ae05142e60ec82ecc9fd093c7f51

Our pub key is also 32 bytes, we need to drop our first byte 04: and trim to 32 bytes.

8b:7c:1d:2e:a7:29:1f:RE:DA:CT:ED:25:27:ca:b1:a8:a9:d1:58:6f:7b:0f:7a:1f:8e:26:2d:29:7d:3a:d2:1a

Shortened by removing : delimiters

8b7c1d2ea7291fREDACTED2527cab1a8a9d1586f7b0f7a1f8e262d297d3ad21a

Using xxd we can convert these into a binary keys, something openssl can easily work with:

since I wasn’t able to perform schnorr_sign using openssl you won’t be using this binary keys; however, these are good skills to have in the tool belt.

$ echo -n 6f72f7f6ead15fe5REDACTED6aa6f53a616ae05142e60ec82ecc9fd093c7f51 | xxd -r -p > priv.bin

$ cat priv.bin
or���R_��&֪oS�.`���	<Q%

And using a tool like hexdump we can see this is infact our expected key

$ hexdump -C priv.bin
00000000  6f 72 f7 f6 ea d1 5f e5  RE DA CT ED d6 aa 6f 53  |or...._7...&..oS|
00000010  a6 16 ae 05 14 2e 60 ec  82 ec c9 fd 09 3c 7f 51  |...8..`......<.Q|
00000020

Lets do the same thing to get a binary representation of our public key:

$ echo -n 8b7c1d2ea7291fREDACTED2527cab1a8a9d1586f7b0f7a1f8e262d297d3ad21a | xxd -r -p > pub.bin

$ cat pub.bin
�|.�)���%'ʱ�Xo{z�&-)}:�%

$ cat pub.bin | hexdump -C
00000000  8b 7c 1d 2e a7 29 1f RE  DA CT ED 25 27 ca b1 a8  |.|...).7...%'...|
00000010  a9 d1 58 6f 7b 0f 7a 1f  8e 26 2d 29 7d 3a d2 1a  |..Po{.8..&-)}:..|
00000020

We could also generate a public key using openssl; however, we will not being using it for our examples.

$ openssl ec -in ec-priv.pem -pubout -out ec-pub.pem

$ openssl ec -in ec-pub.pem -pubin -text -noout
read EC key
Private-Key: (256 bit)
pub:
    04:8b:7c:1d:2e:a7:29:1f:RE:DA:CT:ED:25:27:ca:
    60:5f:ff:5e:84:61:6a:6a:7c:16:22:b7:2e:d7:95:
    f4:02:8a:c3:7a:cb:ed:0a:63:eb:86:1e:bd:aa:17:
    25:fa:a5:92:80:a9:f3:f1:73:07:55:4e:70:cb:f1:
    a0:0e:3f:e4:8e
ASN1 OID: secp256k1

At this point we are ready to dig in to notstr’s protocol, and it is worth understanding the “EVENT” schema:

https://github.com/nostr-protocol/nips/blob/master/01.md

My proof of concept python is a little rough, it was meant to be throw away, but will help with the process:

main.py

import time
from hashlib import sha256
import json

private_key = '6f72f7f6ead15fe5REDACTED6aa6f53a616ae05142e60ec82ecc9fd093c7f51'
public_key = '8b7c1d2ea7291fREDACTED2527cab1a8a9d1586f7b0f7a1f8e262d297d3ad21a'

# build event hash
_event = {
    'kind': 1,
    'created_at': int(time.time()),  # set time to now
    'tags': [],
    'content': input("Message: "),
    'pubkey': public_key
}

# per documentation our id is built by hashing the known data_struct format
data_struct = [
  0,
  _event['pubkey'],
  _event['created_at'],
  _event['kind'],
  _event['tags'],
  _event['content'],
]

# serialize our data_struct and remove white spaces
serialized_json = json.dumps(data_struct, separators=(',', ':')).encode('utf-8')

# hash our serialized_json using sha256
_hash_ = sha256()
_hash_.update(serialized_json)
event_id = _hash_.hexdigest()

# append our hashed value as id
_event['id'] = event_id

At this point our EVENT should look something like this:

{
  "kind": 1,
  "created_at": 1676605199,
  "tags": [],
  "content": "Hello world!",
  "pubkey": "8b7c1d2ea7291fREDACTED2527cab1a8a9d1586f7b0f7a1f8e262d297d3ad21a",
  "id": "6917fee8c38e7b572404d681ecREDACT60b0a6f32f54b1ce6de3752ff0b8f"
}

All that is left is to sign our event_id using our private key… I spent hours trying to properly sign a nostr event, but in the end I gave up and used a 3rd party library.

Lets step away from main.py for a moment, and peak how I began testing signing:

The secp256k1 library helps in perform proper schnorr_sign.

# virtualenv -p python venv/
# source venv/bin/activate
# pip install secp256k1
>> import secp256k1
>>
>> private_key = b'6f72f7f6ead15fe5REDACTED6aa6f53a616ae05142e60ec82ecc9fd093c7f51'
>> sk = secp256k1.PrivateKey(private_key, raw=False)

>>> event_id = bytes.fromhex('6917fee8c38e7b572404d681ecREDACT60b0a6f32f54b1ce6de3752ff0b8f')
>>> sk.schnorr_sign(event_id, None, raw=True).hex()
'ee4555060e58c81e93ca995c836ff98fbd2REDACTdeda5428d080dbd003fc4ef6a00113873568d419c943b28ce957985491639865d0dff56ed146c0109018009'

I hope to eventually learn how to do the same in openssl, but for now lets get back to main.py:

be sure to import secp256k1.

sk = secp256k1.PrivateKey(private_key, raw=False)
event_id = bytes.fromhex(event_id)

_event['sig'] = sk.schnorr_sign(event_id, None, raw=True).hex()

event = ["EVENT", _event]

event_json = json.dumps(event, separators=(',', ':')).encode('utf-8')
print(event_json.decode('utf-8'))

Here is an example of all the pieces working together:

$ python main.py
Message: Hello world!
["EVENT",{"kind":1,"created_at":1676605345,"tags":[],"content":"Hello world!'","pubkey":"8b7c1d2ea7291fREDACTED2527cab1a8a9d1586f7b0f7a1f8e262d297d3ad21a","id":"d18f33bcf81fcf96daa098dcREDACT13bdf13d37b2e6742d7d9a85ef5464dc7","sig":"2b428b1a4c39511b428e01ab8744b20a0b7bc537REDACT037207eecce7e48f1813e5ced58b33a72026f6d0d3e60b0f057ae0e8e33cd31c33d1ca859a444d87"}]

And finally lets get this event posted to a nostr relay.

There are multiple ways to do Websockets, but for simplicity I’m going to use websocat (I installed from brew, but I’m sure it’s in your favorite package manager).

The below command will get us connected over websockets to a open relay (if you are unfamiliar with websockets, you can simply think of it as bi-directional, real-time http for now).

$ websocat wss://nostr.pleb.network

If your connection worked you should be at a blank line, prompt waiting for input.

I’m going to copy/paste the output from main.py

"pubkey":"8b7c1d2ea7291fREDACTED2527cab1a8a9d1586f7b0f7a1f8e262d297d3ad21a","id":"d18f33bcf81fcf96daa098dcREDACT13bdf13d37b2e6742d7d9a85ef5464dc7","sig":"2b428b1a4c39511b428e01ab8744b20a0b7bc537REDACT037207eecce7e48f1813e5ced58b33a72026f6d0d3e60b0f057ae0e8e33cd31c33d1ca859a444d87"}]
["OK","d18f33bcf81fcf96daa098dcREDACT13bdf13d37b2e6742d7d9a85ef5464dc7",true,""]

Excellent, the [“OK”] response was what we were expecting, our message is now posted!