Can Nostr Events Be Manipulated?

If you haven’t been following along, I have a couple of nostr posts at this point.

https://nessy.info/tags/nostr/

I’ve been trying to further understand nostr by deep diving the protocol. At this point my thought is how mutable are nostr messages (events), I understand that during broadcast the relay verifies the signature, but then they need to store these events in some centralized database, right? Could a rogue relay for example accept your event, then alter it at a later time? Well in this post I hope to explore just that.

I’m going to start by setting up a local relay, I will be using nostr-rs-relay, an officially documented and suggested implementations:

https://nostr.com/relays/implementations

Following the build and run documentation with the latest version of Rust I end up with a simple nostr relay running on Linux backed by a sqlite database (neat):

$ RUST_LOG=warn,nostr_rs_relay=info ./target/release/nostr-rs-relay
Apr 18 10:07:30.841  INFO nostr_rs_relay: Starting up from main
Apr 18 10:07:30.845  INFO nostr_rs_relay::server: listening on: 0.0.0.0:8080
Apr 18 10:07:30.851  INFO nostr_rs_relay::repo::sqlite: Built a connection pool "writer" (min=1, max=2)
Apr 18 10:07:30.853  INFO nostr_rs_relay::repo::sqlite: Built a connection pool "maintenance" (min=1, max=2)
Apr 18 10:07:30.857  INFO nostr_rs_relay::repo::sqlite: Built a connection pool "reader" (min=4, max=8)
Apr 18 10:07:30.859  INFO nostr_rs_relay::repo::sqlite_migration: DB version = 18
Apr 18 10:07:30.859  INFO nostr_rs_relay::server: db writer created
Apr 18 10:07:30.860  INFO nostr_rs_relay::server: control message listener started

From here I’m going to use one of my nostr_stuff tools to sign a message:

% python sign.py
public_key: 332adca51c7f7e2fe2f2687152245f0a3bb624cc6f38346eaed3d2d4178ad352
private_key: 646REDACTED8f2c8bd9bREDACTED-REDACTED6fe39e0ae6896e6aeb9ae1e1a1
Message: This is a test

["EVENT",{"kind":1,"created_at":1681830563,"tags":[],"content":"This is a test","pubkey":"332adca51c7f7e2fe2f2687152245f0a3bb624cc6f38346eaed3d2d4178ad352","id":"d3f634cfc5fa826cec27ae31f81b7e9e5e3a338aa7c2eaf40923d6e8ea5293b7","sig":"aed5aa1cafe993c41b3204c5fe3da137e54c5decc3da8508c777481a50fd48ffe35727b3bf03f5bfec3fddc450e3be5ade6d35552b9a0824196e8507847148c2"}]

And then broadcast that message to my local/private relay:

% python send.py
server: ws://192.168.1.46:8080
payload: ["EVENT",{"kind":1,"created_at":1681830563,"tags":[],"content":"This is a test","pubkey":"332adca51c7f7e2fe2f2687152245f0a3bb624cc6f38346eaed3d2d4178ad352","id":"d3f634cfc5fa826cec27ae31f81b7e9e5e3a338aa7c2eaf40923d6e8ea5293b7","sig":"aed5aa1cafe993c41b3204c5fe3da137e54c5decc3da8508c777481a50fd48ffe35727b3bf03f5bfec3fddc450e3be5ade6d35552b9a0824196e8507847148c2"}]
["OK","d3f634cfc5fa826cec27ae31f81b7e9e5e3a338aa7c2eaf40923d6e8ea5293b7",true,""]

Looking back to my relay’s logs I can see the connection and interaction, again, neat:

Apr 18 10:10:29.910  INFO nostr_rs_relay::server: new client connection (cid: e148d513, ip: "192.168.1.31")
Apr 18 10:10:29.910  INFO nostr_rs_relay::server: cid: e148d513, origin: "<unspecified>", user-agent: "Python/3.9 websockets/11.0.1"
Apr 18 10:10:30.862  INFO nostr_rs_relay::repo::sqlite: checkpoint ran in 90.203µs (result: Ok, WAL size: 0)
Apr 18 10:10:35.135  INFO nostr_rs_relay::db: persisted event: "d3f634cf" (kind: 1) from: "332adca5" in: 52.981417ms (IP: "192.168.1.31")
Apr 18 10:10:35.142  INFO nostr_rs_relay::server: stopping client connection (cid: e148d513, ip: "192.168.1.31", sent: 1 events, recv: 0 events, connected: 5.23236815s)

Now let us read from this relay using one of my nostr_stuff tools:

% python read.py
server: ws://192.168.1.46:8080
users pubkey:
minutes [15]:
[2023-04-18 10:09:23][332adca51c7f7e2fe2f2687152245f0a3bb624cc6f38346eaed3d2d4178ad352]
 This is a test

Just as I expected, the event I signed is present.

Now here comes the manipulation part, let’s explore the sqlite database some:

$ sqlite3 nostr.db
SQLite version 3.34.1 2021-01-20 14:10:07
Enter ".help" for usage hints.

sqlite> .tables
account            invoice            user_verification
event              tag

sqlite> .schema event
CREATE TABLE event (
id INTEGER PRIMARY KEY,
event_hash BLOB NOT NULL, -- 4-byte hash
first_seen INTEGER NOT NULL, -- when the event was first seen (not authored!) (seconds since 1970)
created_at INTEGER NOT NULL, -- when the event was authored
expires_at INTEGER, -- when the event expires and may be deleted
author BLOB NOT NULL, -- author pubkey
delegated_by BLOB, -- delegator pubkey (NIP-26)
kind INTEGER NOT NULL, -- event kind
hidden INTEGER, -- relevant for queries
content TEXT NOT NULL -- serialized json of event object
);
CREATE UNIQUE INDEX event_hash_index ON event(event_hash);
CREATE INDEX author_index ON event(author);
CREATE INDEX kind_index ON event(kind);
CREATE INDEX created_at_index ON event(created_at);
CREATE INDEX delegated_by_index ON event(delegated_by);
CREATE INDEX event_composite_index ON event(kind,created_at);
CREATE INDEX kind_author_index ON event(kind,author);
CREATE INDEX kind_created_at_index ON event(kind,created_at);
CREATE INDEX author_created_at_index ON event(author,created_at);
CREATE INDEX author_kind_index ON event(author,kind);
CREATE INDEX event_expiration ON event(expires_at);

As we see there are a few tables here, the one holding our message seems to be event.

If you look closely you can see my message within the json content key:

sqlite> SELECT id, content from event;
1|{"id":"d3f634cfc5fa826cec27ae31f81b7e9e5e3a338aa7c2eaf40923d6e8ea5293b7","pubkey":"332adca51c7f7e2fe2f2687152245f0a3bb624cc6f38346eaed3d2d4178ad352","created_at":1681830563,"kind":1,"tags":[],"content":"This is a test","sig":"aed5aa1cafe993c41b3204c5fe3da137e54c5decc3da8508c777481a50fd48ffe35727b3bf03f5bfec3fddc450e3be5ade6d35552b9a0824196e8507847148c2"}

If a rogue user were to change this content I wonder if the nostr-rs-relay would still serve this to an unaware client?

sqlite> UPDATE event SET content='{"id":"d3f634cfc5fa826cec27ae31f81b7e9e5e3a338aa7c2eaf40923d6e8ea5293b7","pubkey":"332adca51c7f7e2fe2f2687152245f0a3bb624cc6f38346eaed3d2d4178ad352","created_at":1681830563,"kind":1,"tags":[],"content":"HACKED","sig":"aed5aa1cafe993c41b3204c5fe3da137e54c5decc3da8508c777481a50fd48ffe35727b3bf03f5bfec3fddc450e3be5ade6d35552b9a0824196e8507847148c2"}' WHERE id = 1;

As expected I can use SQL to update the content:

sqlite> SELECT id, content from event;
1|{"id":"d3f634cfc5fa826cec27ae31f81b7e9e5e3a338aa7c2eaf40923d6e8ea5293b7","pubkey":"332adca51c7f7e2fe2f2687152245f0a3bb624cc6f38346eaed3d2d4178ad352","created_at":1681830563,"kind":1,"tags":[],"content":"HACKED","sig":"aed5aa1cafe993c41b3204c5fe3da137e54c5decc3da8508c777481a50fd48ffe35727b3bf03f5bfec3fddc450e3be5ade6d35552b9a0824196e8507847148c2"}

Now let’s see if the relay will return that to my unaware/dumb nostr client:

% python read.py
server: ws://192.168.1.46:8080
users pubkey:
minutes [15]:
[2023-04-18 10:09:23][332adca51c7f7e2fe2f2687152245f0a3bb624cc6f38346eaed3d2d4178ad352]
 HACKED

Well damn, even though the signature doesn’t match the server happily returns the content.

What this means is our nostr client need be smart enough to check signature before presenting them, and/or the relay should perform signature verification upon returning events (which certainly would slow down relay).