Anton Johansson

Joining Nostr - The hard way

I stumbled over Nostr today. Nostr is a decentralized protocol for social networking that relies heavily on public key infrastructure, which is quite cool.

While browsing through the various Nostr clients I noticed that many required the user to disclose their private key to the 3rd party client. Although I believe many clients acts in good faith here and just wants to make the life easier for the user, this is a bad pattern in my opinion.

I had a peek at the protocol, and it's actually very simple and approachable. The spec is built up from Nostr Implementation Possibilities, or NIPs and only NIP01 is required by all parties.

It seemed so simple in fact that I decided try this out the hard way and handcraft a couple of Nostr events.

Key Generation

I started by generating a fresh keypair for my Nostr profile. NIP01 specifies that secp256k1 keys are used on Nostr

openssl ecparam -genkey -name secp256k1 -out nostr.pem

Next I exported the public key1 into compressed representation

openssl ec -pubout -in nostr.pem -conv_form compressed -out nostr-pub.pem -outform PEM

Before I could start crafting events, I needed to export my public key bytes

$ openssl pkey -in nostr-pub.pem -pubin -outform DER |  tail -c 33 | xxd -p -c 65
0272ad61a0c2fb73fba6b0e51c3c770453972027bc068c73b3766001f6b98ef592

Nostr uses the Schnorr signature standards as specified in BIP-340 which impacts signatures and public key encodings, so there are two things to consider:

First: The public key must start with 0x02. If not, regenerate the key until one starts with 0x02.

Second: As the key starts with 0x02, the first byte should be omitted

The details on why is elaborated on in BIP-340

Crafting the events

In NIP01 an event is defined using the following format

{
  "id": <32-bytes lowercase hex-encoded sha256 of the serialized event data>,
  "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
  "created_at": <unix timestamp in seconds>,
  "kind": <integer between 0 and 65535>,
  "tags": [
    [<arbitrary string>...],
    ...
  ],
  "content": <arbitrary string>,
  "sig": <64-bytes lowercase hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field>
}

My plan was to send two events

  1. Register my profile metadata
  2. Send a status note

To reduce the length of this post, I'll only cover the details on the first event. These two actions requires similar preparations, and are separated only by the value of the kind field and structure of the content field.

Deriving the ID

I started by deriving the id for the event, which is the SHA256 of the serialized event. My first event was

{
  "pubkey": "72ad61a0c2fb73fba6b0e51c3c770453972027bc068c73b3766001f6b98ef592",
  "created_at": 1693766915,
  "kind": 0,
  "tags": [],
  "content": "{\"name\":\"AntonJohansson\",\"about\":\"Trying out this Nostr thing\"}",
}

To derive the id I simply serialized and hashed the event

echo -n '[0,"72ad61a0c2fb73fba6b0e51c3c770453972027bc068c73b3766001f6b98ef592",1693766915,0,[],"{\"name\":\"AntonJohansson\",\"about\":\"Trying out this Nostr thing\"}"]' | shasum -a 256
e91d823f59e25429e4c1cba2678180d99451e14fbd6130b4ece427afa8e38ffe

Giving me the partial event

{
  "id": "e91d823f59e25429e4c1cba2678180d99451e14fbd6130b4ece427afa8e38ffe",
  "pubkey": "72ad61a0c2fb73fba6b0e51c3c770453972027bc068c73b3766001f6b98ef592",
  "created_at": 1693766915,
  "kind": 0,
  "tags": [],
  "content": "{\"name\":\"AntonJohansson\",\"about\":\"Trying out this Nostr thing\"}",
}

Signing the event

As far as I know Schnorr is not implemented in OpenSSL at the time of writing. This was a bit unfortunate, as I hoped to be able to do all operations on standard pre-installed CLI applications. Anyhow, to solve this I wrote a minimal signing application in JavaScript which is available on my Github account.

The application gave me the resulting signature

f8fe39ca29f7ef5ef551763a360e9fcc6aa803a6ed0001dbf7d8e5d4675f4e9e978b2a32c2081b75ba0b1a84c0e9c11eecc80e52c082875d6105963d43f4431e

Which I plopped in the event body, creating the completed event

{
  "id": "e91d823f59e25429e4c1cba2678180d99451e14fbd6130b4ece427afa8e38ffe",
  "pubkey": "72ad61a0c2fb73fba6b0e51c3c770453972027bc068c73b3766001f6b98ef592",
  "created_at": 1693766915,
  "kind": 0,
  "tags": [],
  "content": "{\"name\":\"AntonJohansson\",\"about\":\"Trying out this Nostr thing\"}",
  "sig": "f8fe39ca29f7ef5ef551763a360e9fcc6aa803a6ed0001dbf7d8e5d4675f4e9e978b2a32c2081b75ba0b1a84c0e9c11eecc80e52c082875d6105963d43f4431e"
}

Following the same procedure, I also created an event for my first post

{
  "id": "a107f2ab6f5cfed8fd155929aa055b902c04f48a17e5c8df837adb117cd30f68",
  "pubkey": "72ad61a0c2fb73fba6b0e51c3c770453972027bc068c73b3766001f6b98ef592",
  "created_at": 1693767135,
  "kind": 1,
  "tags": [],
  "content": "Hello World!",
  "sig": "11a313a7fd353a70f66fda5341f8c4ffa22334a692fde16c00988fc439959cdb3635a1b109373e7a628b74ec4fee28449355b9c08601ca39b2cc95750bf2e5b2"
}

Finally, I encoded the events for transport

cat metadata.json
["EVENT", {
  "id": "e91d823f59e25429e4c1cba2678180d99451e14fbd6130b4ece427afa8e38ffe",
  "pubkey": "72ad61a0c2fb73fba6b0e51c3c770453972027bc068c73b3766001f6b98ef592",
  "created_at": 1693766915,
  "kind": 0,
  "tags": [],
  "content": "{\"name\":\"AntonJohansson\",\"about\":\"Trying out this Nostr thing\"}",
  "sig": "f8fe39ca29f7ef5ef551763a360e9fcc6aa803a6ed0001dbf7d8e5d4675f4e9e978b2a32c2081b75ba0b1a84c0e9c11eecc80e52c082875d6105963d43f4431e"
}]

cat post.json
["EVENT", {
  "id": "a107f2ab6f5cfed8fd155929aa055b902c04f48a17e5c8df837adb117cd30f68",
  "pubkey": "72ad61a0c2fb73fba6b0e51c3c770453972027bc068c73b3766001f6b98ef592",
  "created_at": 1693767135,
  "kind": 1,
  "tags": [],
  "content": "Hello World!",
  "sig": "11a313a7fd353a70f66fda5341f8c4ffa22334a692fde16c00988fc439959cdb3635a1b109373e7a628b74ec4fee28449355b9c08601ca39b2cc95750bf2e5b2"
}]

Sending the events to a Relay

Next up was finding a relay and send the events. To find a relay I browsed the relay register nostr.watch and picked the public relay nos.lol. As Nostr uses Websockets as transport protocol I used the neat tool websocat to send the events from my terminal

cat metadata.json | jq -c | websocat wss://nos.lol/
["OK","e91d823f59e25429e4c1cba2678180d99451e14fbd6130b4ece427afa8e38ffe",true,""]

cat post.json | jq -c | websocat wss://nos.lol/
["OK","a107f2ab6f5cfed8fd155929aa055b902c04f48a17e5c8df837adb117cd30f68",true,""]

To verify that that the events had propagated I used nostrcheck to get my public ID npub1w2kkrgxzldelhf4su5wrcacy2wtjqfauq6x88vmkvqqldwvw7kfqzc3x76 to making it easier to find my profile using a client.

Lo and behold, both the metadata and note events propagated 🎉

https://iris.to/npub1w2kkrgxzldelhf4su5wrcacy2wtjqfauq6x88vmkvqqldwvw7kfqzc3x76

Final thoughts

This was quite a fun exercise, and it was quite refreshing to fiddle with such a simple protocol. In the future I probably use a CLI likenostril however or a desktop client.

  1. I generally prefer the PEM format for keys, but you'll notice that I do a few conversions to DER here and there when needed