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).